diff --git a/iOS/PostEditor/PostBodyTextView.swift b/iOS/PostEditor/PostBodyTextView.swift index 2de4636..2eaef56 100644 --- a/iOS/PostEditor/PostBodyTextView.swift +++ b/iOS/PostEditor/PostBodyTextView.swift @@ -1,64 +1,89 @@ -// Based on https://stackoverflow.com/a/56508132/1234545 +// Based on https://stackoverflow.com/a/56508132/1234545 and https://stackoverflow.com/a/48360549/1234545 import SwiftUI -struct PostBodyTextView: UIViewRepresentable { +class PostBodyCoordinator: NSObject, UITextViewDelegate, NSLayoutManagerDelegate { + @Binding var text: String + @Binding var isFirstResponder: Bool + var lineSpacingMultiplier: CGFloat + var didBecomeFirstResponder: Bool = false + var postBodyTextView: PostBodyTextView - class Coordinator: NSObject, UITextViewDelegate { - @Binding var text: String - @Binding var isFirstResponder: Bool - var didBecomeFirstResponder: Bool = false + weak var textView: UITextView? - init(text: Binding, isFirstResponder: Binding) { - _text = text - _isFirstResponder = isFirstResponder - } + init( + _ textView: PostBodyTextView, + text: Binding, + isFirstResponder: Binding, + lineSpacingMultiplier: CGFloat + ) { + self.postBodyTextView = textView + _text = text + _isFirstResponder = isFirstResponder + self.lineSpacingMultiplier = lineSpacingMultiplier + } - func textViewDidChangeSelection(_ textView: UITextView) { - DispatchQueue.main.async { - self.text = textView.text ?? "" - } + 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 + } +} + +struct PostBodyTextView: UIViewRepresentable { @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.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() -> PostBodyTextView.Coordinator { - return Coordinator(text: $text, isFirstResponder: $isFirstResponder) + func makeCoordinator() -> PostBodyCoordinator { + return Coordinator( + self, + text: $text, + isFirstResponder: $isFirstResponder, + lineSpacingMultiplier: lineSpacing + ) } func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext) { - 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.text = text - 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 0f85e84..74b6857 100644 --- a/iOS/PostEditor/PostTextEditingView.swift +++ b/iOS/PostEditor/PostTextEditingView.swift @@ -1,103 +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 { 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: 17 * ( - horizontalSizeClass == .compact ? bodyLineSpacingMultiplier / 2 : bodyLineSpacingMultiplier - ) + lineSpacing: 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: 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 434722c..72af11c 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 { +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 textViewDidChangeSelection(_ textView: UITextView) { + 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 } 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 { + 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 } } }