diff --git a/Shared/PostEditor/PostEditorModel.swift b/Shared/PostEditor/PostEditorModel.swift index ca8959f..84e774c 100644 --- a/Shared/PostEditor/PostEditorModel.swift +++ b/Shared/PostEditor/PostEditorModel.swift @@ -1,38 +1,38 @@ import Foundation import CoreData enum PostAppearance: String { case sans = "OpenSans-Regular" - case mono = "Hack" - case serif = "Lora" + case mono = "Hack-Regular" + case serif = "Lora-Regular" } struct PostEditorModel { let lastDraftObjectURLKey = "lastDraftObjectURLKey" private(set) var lastDraft: WFAPost? mutating func setLastDraft(_ post: WFAPost) { lastDraft = post UserDefaults.standard.set(post.objectID.uriRepresentation(), forKey: lastDraftObjectURLKey) } mutating func fetchLastDraft() -> WFAPost? { let coordinator = LocalStorageManager.persistentContainer.persistentStoreCoordinator // See if we have a lastDraftObjectURI guard let lastDraftObjectURI = UserDefaults.standard.url(forKey: lastDraftObjectURLKey) else { return nil } // See if we can get an ObjectID from the URI representation guard let lastDraftObjectID = coordinator.managedObjectID(forURIRepresentation: lastDraftObjectURI) else { return nil } lastDraft = LocalStorageManager.persistentContainer.viewContext.object(with: lastDraftObjectID) as? WFAPost return lastDraft } mutating func clearLastDraft() { lastDraft = nil UserDefaults.standard.removeObject(forKey: lastDraftObjectURLKey) } } diff --git a/iOS/PostEditor/PostBodyTextView.swift b/iOS/PostEditor/PostBodyTextView.swift index ba7713c..2de4636 100644 --- a/iOS/PostEditor/PostBodyTextView.swift +++ b/iOS/PostEditor/PostBodyTextView.swift @@ -1,54 +1,64 @@ // Based on https://stackoverflow.com/a/56508132/1234545 import SwiftUI struct PostBodyTextView: UIViewRepresentable { class Coordinator: NSObject, UITextViewDelegate { @Binding var text: String @Binding var isFirstResponder: Bool var didBecomeFirstResponder: Bool = false init(text: Binding, isFirstResponder: Binding) { _text = text _isFirstResponder = isFirstResponder } func textViewDidChangeSelection(_ textView: UITextView) { DispatchQueue.main.async { self.text = textView.text ?? "" } } } @Binding var text: String @Binding var textStyle: UIFont @Binding var isFirstResponder: Bool + var lineSpacing: CGFloat func makeUIView(context: UIViewRepresentableContext) -> UITextView { let textView = UITextView(frame: .zero) textView.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() -> PostBodyTextView.Coordinator { return Coordinator(text: $text, isFirstResponder: $isFirstResponder) } func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext) { - uiView.text = text + let attributedString = NSMutableAttributedString(string: text) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = lineSpacing + attributedString.addAttribute( + NSAttributedString.Key.paragraphStyle, + value: paragraphStyle, + range: NSMakeRange(0, attributedString.length) // swiftlint:disable:this legacy_constructor + ) + + uiView.attributedText = attributedString 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/PostTextEditingView.swift b/iOS/PostEditor/PostTextEditingView.swift index c24530e..0f85e84 100644 --- a/iOS/PostEditor/PostTextEditingView.swift +++ b/iOS/PostEditor/PostTextEditingView.swift @@ -1,68 +1,103 @@ 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 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 { - TextField("Title (optional)", text: $post.title) - .font(.custom(appearance.rawValue, size: 26, relativeTo: .largeTitle)) - .padding(.horizontal, 4) + 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(.custom(appearance.rawValue, size: 17, relativeTo: .body)) + .font(Font(bodyTextStyle)) .foregroundColor(Color(UIColor.placeholderText)) .padding(.horizontal, 4) .padding(.vertical, 8) } - TextEditor(text: $post.body) - .font(.custom(appearance.rawValue, size: 17, relativeTo: .body)) - .lineSpacing( - 17 * ( - horizontalSizeClass == .compact ? bodyLineSpacingMultiplier / 2 : bodyLineSpacingMultiplier - ) + PostBodyTextView( + text: $post.body, + textStyle: $bodyTextStyle, + isFirstResponder: $bodyIsFirstResponder, + lineSpacing: 17 * ( + horizontalSizeClass == .compact ? bodyLineSpacingMultiplier / 2 : bodyLineSpacingMultiplier ) - .onChange(of: post.body) { _ in - if post.status == PostStatus.published.rawValue && !updatingBodyFromServer { - post.status = PostStatus.edited.rawValue - } - if updatingBodyFromServer { - updatingBodyFromServer = false - } + ) + .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 c29f843..434722c 100644 --- a/iOS/PostEditor/PostTitleTextView.swift +++ b/iOS/PostEditor/PostTitleTextView.swift @@ -1,95 +1,95 @@ // Based on https://lostmoa.com/blog/DynamicHeightForTextFieldInSwiftUI/ // and https://stackoverflow.com/a/56508132/1234545 import SwiftUI class Coordinator: 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 textViewDidChangeSelection(_ textView: UITextView) { DispatchQueue.main.async { self.postTitleTextView.text = textView.text ?? "" } } func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - if (text == "\n") { + 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 } 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() -> Coordinator { 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 } } }