diff --git a/iOS/PostEditor/PostBodyTextView.swift b/iOS/PostEditor/PostBodyTextView.swift index 2eaef56..7530fd5 100644 --- a/iOS/PostEditor/PostBodyTextView.swift +++ b/iOS/PostEditor/PostBodyTextView.swift @@ -1,89 +1,102 @@ // Based on https://stackoverflow.com/a/56508132/1234545 and https://stackoverflow.com/a/48360549/1234545 import SwiftUI class PostBodyCoordinator: NSObject, UITextViewDelegate, NSLayoutManagerDelegate { @Binding var text: String @Binding var isFirstResponder: Bool var lineSpacingMultiplier: CGFloat var didBecomeFirstResponder: Bool = false var postBodyTextView: PostBodyTextView weak var textView: UITextView? init( _ textView: PostBodyTextView, text: Binding, isFirstResponder: Binding, lineSpacingMultiplier: CGFloat ) { self.postBodyTextView = textView _text = text _isFirstResponder = isFirstResponder self.lineSpacingMultiplier = lineSpacingMultiplier } func textViewDidChange(_ textView: UITextView) { DispatchQueue.main.async { self.postBodyTextView.text = textView.text ?? "" } } func layoutManager( _ layoutManager: NSLayoutManager, lineSpacingAfterGlyphAt glyphIndex: Int, withProposedLineFragmentRect rect: CGRect ) -> CGFloat { - return 17 * lineSpacingMultiplier + // HACK: - This seems to be the only way to get line spacing to update dynamically on iPad + // when switching between full-screen, split-screen, and slide-over views. + if let window = UIApplication.shared.windows.filter({ $0.isKeyWindow }).first { + // Get the width of the window to determine the size class + if window.frame.width < 600 { + // Use 0.25 multiplier for compact size class + return 17 * 0.25 + } else { + // Use 0.5 multiplier otherwise + return 17 * 0.5 + } + } else { + return 17 * lineSpacingMultiplier + } } } struct PostBodyTextView: UIViewRepresentable { @Binding var text: String @Binding var textStyle: UIFont @Binding var isFirstResponder: Bool - var lineSpacing: CGFloat + @State var lineSpacing: CGFloat func makeUIView(context: UIViewRepresentableContext) -> UITextView { let textView = UITextView(frame: .zero) textView.isEditable = true textView.isUserInteractionEnabled = true textView.isScrollEnabled = true textView.alwaysBounceVertical = false context.coordinator.textView = textView textView.delegate = context.coordinator textView.layoutManager.delegate = context.coordinator let font = textStyle let fontMetrics = UIFontMetrics(forTextStyle: .largeTitle) textView.font = fontMetrics.scaledFont(for: font) textView.backgroundColor = UIColor.clear return textView } func makeCoordinator() -> PostBodyCoordinator { return Coordinator( self, text: $text, isFirstResponder: $isFirstResponder, lineSpacingMultiplier: lineSpacing ) } func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext) { uiView.text = text let font = textStyle let fontMetrics = UIFontMetrics(forTextStyle: .largeTitle) uiView.font = fontMetrics.scaledFont(for: font) // We don't want the text field to become first responder every time SwiftUI refreshes the view. if isFirstResponder && !context.coordinator.didBecomeFirstResponder { uiView.becomeFirstResponder() context.coordinator.didBecomeFirstResponder = true } } } diff --git a/iOS/PostEditor/PostEditorView.swift b/iOS/PostEditor/PostEditorView.swift index 818d4bd..36d05f6 100644 --- a/iOS/PostEditor/PostEditorView.swift +++ b/iOS/PostEditor/PostEditorView.swift @@ -1,246 +1,245 @@ import SwiftUI struct PostEditorView: View { @EnvironmentObject var model: WriteFreelyModel @Environment(\.managedObjectContext) var moc - @Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.presentationMode) var presentationMode @ObservedObject var post: WFAPost @State private var updatingTitleFromServer: Bool = false @State private var updatingBodyFromServer: Bool = false @State private var selectedCollection: WFACollection? @FetchRequest( entity: WFACollection.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \WFACollection.title, ascending: true)] ) var collections: FetchedResults var body: some View { VStack { if post.hasNewerRemoteCopy { RemoteChangePromptView( remoteChangeType: .remoteCopyUpdated, buttonHandler: { model.updateFromServer(post: post) } ) } else if post.wasDeletedFromServer { RemoteChangePromptView( remoteChangeType: .remoteCopyDeleted, buttonHandler: { self.presentationMode.wrappedValue.dismiss() DispatchQueue.main.async { model.posts.remove(post) } } ) } PostTextEditingView( post: _post, updatingTitleFromServer: $updatingTitleFromServer, updatingBodyFromServer: $updatingBodyFromServer ) } .navigationBarTitleDisplayMode(.inline) .padding() .toolbar { ToolbarItem(placement: .principal) { PostEditorStatusToolbarView(post: post) } ToolbarItem(placement: .primaryAction) { if model.isProcessingRequest { ProgressView() } else { Menu(content: { if post.status == PostStatus.local.rawValue { Menu(content: { Label("Publish to…", systemImage: "paperplane") Button(action: { if model.account.isLoggedIn { post.collectionAlias = nil publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Text(" \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")") }) ForEach(collections) { collection in Button(action: { if model.account.isLoggedIn { post.collectionAlias = collection.alias publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Text(" \(collection.title)") }) } }, label: { Label("Publish…", systemImage: "paperplane") }) } else { Button(action: { if model.account.isLoggedIn { publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Label("Publish", systemImage: "paperplane") }) .disabled(post.status == PostStatus.published.rawValue || post.body.count == 0) } Button(action: { sharePost() }, label: { Label("Share", systemImage: "square.and.arrow.up") }) .disabled(post.postId == nil) // Button(action: { // print("Tapped 'Delete...' button") // }, label: { // Label("Delete…", systemImage: "trash") // }) if model.account.isLoggedIn && post.status != PostStatus.local.rawValue { Section(header: Text("Move To Collection")) { Label("Move to:", systemImage: "arrowshape.zigzag.right") Picker(selection: $selectedCollection, label: Text("Move to…")) { Text( " \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")" ).tag(nil as WFACollection?) ForEach(collections) { collection in Text(" \(collection.title)").tag(collection as WFACollection?) } } } } }, label: { Image(systemName: "ellipsis.circle") }) } } } .onChange(of: post.hasNewerRemoteCopy, perform: { _ in if !post.hasNewerRemoteCopy { updatingTitleFromServer = true updatingBodyFromServer = true } }) .onChange(of: post.status, perform: { _ in if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { model.editor.setLastDraft(post) } } else { DispatchQueue.main.async { model.editor.clearLastDraft() } } }) .onChange(of: selectedCollection, perform: { [selectedCollection] newCollection in if post.collectionAlias == newCollection?.alias { return } else { post.collectionAlias = newCollection?.alias model.move(post: post, from: selectedCollection, to: newCollection) } }) .onAppear(perform: { self.selectedCollection = collections.first { $0.alias == post.collectionAlias } }) .onDisappear(perform: { if post.title.count == 0 && post.body.count == 0 && post.status == PostStatus.local.rawValue && post.updatedDate == nil && post.postId == nil { DispatchQueue.main.async { model.posts.remove(post) model.posts.loadCachedPosts() } } else if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { LocalStorageManager().saveContext() } } }) } private func publishPost() { DispatchQueue.main.async { LocalStorageManager().saveContext() model.posts.loadCachedPosts() model.publish(post: post) } #if os(iOS) self.hideKeyboard() #endif } private func sharePost() { // If the post doesn't have a post ID, it isn't published, and therefore can't be shared, so return early. guard let postId = post.postId else { return } var urlString: String if let postSlug = post.slug, let postCollectionAlias = post.collectionAlias { // This post is in a collection, so share the URL as server/collectionAlias/postSlug. urlString = "\(model.account.server)/\((postCollectionAlias))/\((postSlug))" } else { // This is a draft post, so share the URL as server/postID urlString = "\(model.account.server)/\((postId))" } guard let data = URL(string: urlString) else { return } let activityView = UIActivityViewController(activityItems: [data], applicationActivities: nil) UIApplication.shared.windows.first?.rootViewController?.present(activityView, animated: true, completion: nil) if UIDevice.current.userInterfaceIdiom == .pad { activityView.popoverPresentationController?.permittedArrowDirections = .up activityView.popoverPresentationController?.sourceView = UIApplication.shared.windows.first activityView.popoverPresentationController?.sourceRect = CGRect( x: UIScreen.main.bounds.width, y: -125, width: 200, height: 200 ) } } } struct PostEditorView_EmptyPostPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let testPost = WFAPost(context: context) testPost.createdDate = Date() testPost.appearance = "norm" let model = WriteFreelyModel() return PostEditorView(post: testPost) .environment(\.managedObjectContext, context) .environmentObject(model) } } struct PostEditorView_ExistingPostPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let testPost = WFAPost(context: context) testPost.title = "Test Post Title" testPost.body = "Here's some cool sample body text." testPost.createdDate = Date() testPost.appearance = "code" testPost.hasNewerRemoteCopy = true let model = WriteFreelyModel() return PostEditorView(post: testPost) .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/iOS/PostEditor/PostTextEditingView.swift b/iOS/PostEditor/PostTextEditingView.swift index 74b6857..eede6da 100644 --- a/iOS/PostEditor/PostTextEditingView.swift +++ b/iOS/PostEditor/PostTextEditingView.swift @@ -1,103 +1,101 @@ import SwiftUI struct PostTextEditingView: View { @Environment(\.horizontalSizeClass) var horizontalSizeClass @ObservedObject var post: WFAPost @Binding var updatingTitleFromServer: Bool @Binding var updatingBodyFromServer: Bool @State private var appearance: PostAppearance = .serif @State private var titleTextStyle: UIFont = UIFont(name: "Lora-Regular", size: 26)! @State private var titleTextHeight: CGFloat = 50 @State private var titleIsFirstResponder: Bool = true @State private var bodyTextStyle: UIFont = UIFont(name: "Lora-Regular", size: 17)! @State private var bodyIsFirstResponder: Bool = false - private let bodyLineSpacingMultiplier: CGFloat = 0.5 + private let lineSpacingMultiplier: CGFloat = 0.5 init( post: ObservedObject, updatingTitleFromServer: Binding, updatingBodyFromServer: Binding ) { self._post = post self._updatingTitleFromServer = updatingTitleFromServer self._updatingBodyFromServer = updatingBodyFromServer UITextView.appearance().backgroundColor = .clear } var titleFieldHeight: CGFloat { let minHeight: CGFloat = 50 if titleTextHeight < minHeight { return minHeight } return titleTextHeight } var body: some View { VStack { ZStack(alignment: .topLeading) { if post.title.count == 0 { Text("Title (optional)") .font(Font(titleTextStyle)) .foregroundColor(Color(UIColor.placeholderText)) .padding(.horizontal, 4) .padding(.vertical, 8) } PostTitleTextView( text: $post.title, textStyle: $titleTextStyle, height: $titleTextHeight, isFirstResponder: $titleIsFirstResponder ) .frame(height: titleFieldHeight) .onChange(of: post.title) { _ in if post.status == PostStatus.published.rawValue && !updatingTitleFromServer { post.status = PostStatus.edited.rawValue } if updatingTitleFromServer { updatingTitleFromServer = false } } } ZStack(alignment: .topLeading) { if post.body.count == 0 { Text("Write…") .font(Font(bodyTextStyle)) .foregroundColor(Color(UIColor.placeholderText)) .padding(.horizontal, 4) .padding(.vertical, 8) } PostBodyTextView( text: $post.body, textStyle: $bodyTextStyle, isFirstResponder: $bodyIsFirstResponder, - lineSpacing: horizontalSizeClass == .compact - ? bodyLineSpacingMultiplier / 2 - : bodyLineSpacingMultiplier + lineSpacing: horizontalSizeClass == .compact ? lineSpacingMultiplier / 2 : lineSpacingMultiplier ) .onChange(of: post.body) { _ in if post.status == PostStatus.published.rawValue && !updatingBodyFromServer { post.status = PostStatus.edited.rawValue } if updatingBodyFromServer { updatingBodyFromServer = false } } } } .onChange(of: titleIsFirstResponder, perform: { _ in self.bodyIsFirstResponder.toggle() }) .onAppear(perform: { switch post.appearance { case "sans": self.appearance = .sans case "wrap", "mono", "code": self.appearance = .mono default: self.appearance = .serif } self.titleTextStyle = UIFont(name: appearance.rawValue, size: 26)! self.bodyTextStyle = UIFont(name: appearance.rawValue, size: 17)! }) } } diff --git a/iOS/PostEditor/PostTitleTextView.swift b/iOS/PostEditor/PostTitleTextView.swift index 72af11c..531feee 100644 --- a/iOS/PostEditor/PostTitleTextView.swift +++ b/iOS/PostEditor/PostTitleTextView.swift @@ -1,95 +1,92 @@ -// Based on https://lostmoa.com/blog/DynamicHeightForTextFieldInSwiftUI/ -// and https://stackoverflow.com/a/56508132/1234545 +// Based on https://lostmoa.com/blog/DynamicHeightForTextFieldInSwiftUI and https://stackoverflow.com/a/56508132/1234545 import SwiftUI class PostTitleCoordinator: NSObject, UITextViewDelegate, NSLayoutManagerDelegate { @Binding var text: String @Binding var isFirstResponder: Bool var didBecomeFirstResponder: Bool = false var postTitleTextView: PostTitleTextView weak var textView: UITextView? init(_ textView: PostTitleTextView, text: Binding, isFirstResponder: Binding) { self.postTitleTextView = textView _text = text _isFirstResponder = isFirstResponder } func textViewDidChange(_ textView: UITextView) { DispatchQueue.main.async { self.postTitleTextView.text = textView.text ?? "" } } func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { if text == "\n" { self.isFirstResponder.toggle() return false } return true } func layoutManager( _ layoutManager: NSLayoutManager, didCompleteLayoutFor textContainer: NSTextContainer?, atEnd layoutFinishedFlag: Bool ) { DispatchQueue.main.async { - guard let view = self.textView else { - return - } + guard let view = self.textView else { return } let size = view.sizeThatFits(view.bounds.size) if self.postTitleTextView.height != size.height { self.postTitleTextView.height = size.height } } } } struct PostTitleTextView: UIViewRepresentable { @Binding var text: String @Binding var textStyle: UIFont @Binding var height: CGFloat @Binding var isFirstResponder: Bool func makeUIView(context: UIViewRepresentableContext) -> UITextView { let textView = UITextView() textView.isEditable = true textView.isUserInteractionEnabled = true textView.isScrollEnabled = true textView.alwaysBounceVertical = false context.coordinator.textView = textView textView.delegate = context.coordinator textView.layoutManager.delegate = context.coordinator let font = textStyle let fontMetrics = UIFontMetrics(forTextStyle: .largeTitle) textView.font = fontMetrics.scaledFont(for: font) textView.backgroundColor = UIColor.clear return textView } func makeCoordinator() -> PostTitleCoordinator { return Coordinator(self, text: $text, isFirstResponder: $isFirstResponder) } func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext) { uiView.text = text let font = textStyle let fontMetrics = UIFontMetrics(forTextStyle: .largeTitle) uiView.font = fontMetrics.scaledFont(for: font) // We don't want the text field to become first responder every time SwiftUI refreshes the view. if isFirstResponder && !context.coordinator.didBecomeFirstResponder { uiView.becomeFirstResponder() context.coordinator.didBecomeFirstResponder = true } } }