diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift index e71612df..ffcc3003 100644 --- a/damus/Views/PostView.swift +++ b/damus/Views/PostView.swift @@ -56,7 +56,6 @@ struct PostView: View { @State var filtered_pubkeys: Set = [] @State var focusWordAttributes: (String?, NSRange?) = (nil, nil) @State var newCursorIndex: Int? - @State var caretRect: CGRect = CGRectNull @State var textHeight: CGFloat? = nil @State var mediaToUpload: MediaUpload? = nil @@ -220,13 +219,6 @@ struct PostView: View { self.newCursorIndex = nil }, updateCursorPosition: { newCursorIndex in self.newCursorIndex = newCursorIndex - }, onCaretRectChange: { uiView in - // When the caret position changes, we change the `caretRect` in our state, so that our ghost caret will follow our caret - if let selectedStartRange = uiView.selectedTextRange?.start { - DispatchQueue.main.async { - caretRect = uiView.caretRect(for: selectedStartRange) - } - } }) .environmentObject(tagModel) .focused($focus) @@ -316,9 +308,6 @@ struct PostView: View { func Editor(deviceSize: GeometryProxy) -> some View { HStack(alignment: .top, spacing: 0) { - if(caretRect != CGRectNull) { - GhostCaret - } VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top) { ProfilePicView(pubkey: damus_state.pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation) @@ -340,25 +329,6 @@ struct PostView: View { } } - // The GhostCaret is a vertical projection of the editor's caret that should sit beside the editor. - // The purpose of this view is create a reference point that we can scroll our ScrollView into - // This is necessary as a bridge to communicate between: - // - The UIKit-based UITextView (which has the caret position) - // - and the SwiftUI-based ScrollView/ScrollReader (where scrolling commands can only be done via the SwiftUI "ID" parameter - var GhostCaret: some View { - Rectangle() - .foregroundStyle(DEBUG_SHOW_GHOST_CARET_VIEW ? .cyan : .init(red: 0, green: 0, blue: 0, opacity: 0)) - .frame( - width: DEBUG_SHOW_GHOST_CARET_VIEW ? caretRect.width : 0, - height: caretRect.height) - // Use padding to vertically align our ghost caret with our actual text caret. - // Note: Programmatic scrolling cannot be done with the `.position` modifier. - // Experiments revealed that the scroller ignores the position modifier. - .padding(.top, caretRect.origin.y) - .id(GHOST_CARET_VIEW_ID) - .disabled(true) - } - func fill_target_content(target: PostTarget) { self.post = initialString() self.tagModel.diff = post.string.count @@ -396,13 +366,6 @@ struct PostView: View { .onAppear { scroll_to_event(scroller: scroller, id: "post", delay: 1.0, animate: true, anchor: .top) } - // Note: The scroll commands below are specific because there seems to be quirk with ScrollReader where sending it to the exact same position twice resets its scroll position. - .onChange(of: caretRect.origin.y, perform: { newValue in - scroller.scrollTo(GHOST_CARET_VIEW_ID) - }) - .onChange(of: searchingIsNil, perform: { newValue in - scroller.scrollTo(GHOST_CARET_VIEW_ID) - }) } // This if-block observes @ for tagging diff --git a/damus/Views/TextViewWrapper.swift b/damus/Views/TextViewWrapper.swift index dac5ad22..9ebd140d 100644 --- a/damus/Views/TextViewWrapper.swift +++ b/damus/Views/TextViewWrapper.swift @@ -7,10 +7,6 @@ import SwiftUI -// Defines how much extra bottom spacing will be applied after the text. -// This will avoid jitters when applying new lines, by ensuring it has enough space until the height is updated on the next view update cycle -let TEXT_BOX_BOTTOM_MARGIN_OFFSET: CGFloat = 30.0 - struct TextViewWrapper: UIViewRepresentable { @Binding var attributedText: NSMutableAttributedString @EnvironmentObject var tagModel: TagModel @@ -19,19 +15,16 @@ struct TextViewWrapper: UIViewRepresentable { let cursorIndex: Int? var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil let updateCursorPosition: ((Int) -> Void) - let onCaretRectChange: ((UITextView) -> Void) func makeUIView(context: Context) -> UITextView { let textView = UITextView() textView.delegate = context.coordinator - // Scroll has to be enabled. When this is disabled, the text input will overflow horizontally, even when its frame's width is limited. - textView.isScrollEnabled = true - // However, a scrolling text box inside of its parent scrollview does not provide a very good experience. We should have the textbox expand vertically - // To simulate that the text box can expand vertically, we will listen to text changes and dynamically change the text box height in response. - // Add an observer so that we can adapt the height of the text input whenever the text changes. - textView.addObserver(context.coordinator, forKeyPath: "contentSize", options: .new, context: nil) - textView.showsVerticalScrollIndicator = false + // Disable scrolling (this view will expand vertically as needed to fit text) + textView.isScrollEnabled = false + // Set low content compression resistance to make this view wrap lines of text, and avoid text overflowing to the right + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.setContentCompressionResistancePriority(.required, for: .vertical) TextViewWrapper.setTextProperties(textView) return textView @@ -52,9 +45,26 @@ struct TextViewWrapper: UIViewRepresentable { setCursorPosition(textView: uiView) let range = uiView.selectedRange + // Set the text height that will fit all the text + // This is needed because the UIKit auto-layout prefers to overflow the text to the right than to expand the text box vertically, even with low horizontal compression resistance + self.setIdealHeight(uiView: uiView) + uiView.selectedRange = NSRange(location: range.location + tagModel.diff, length: range.length) tagModel.diff = 0 } + + /// Based on our desired layout, calculate the ideal size of the text box, then set the height to the ideal size + private func setIdealHeight(uiView: UITextView) { + DispatchQueue.main.async { // Queue on main thread, because modifying view state directly during re-render causes undefined behavior + let idealSize = uiView.sizeThatFits(CGSize( + width: uiView.frame.width, // We want to stay within the horizontal bounds given to us + height: .infinity // We can expand vertically without any resistance + )) + if self.textHeight != idealSize.height { // Only update height when it changes, to avoid infinite re-render calls + self.textHeight = idealSize.height + } + } + } private func setCursorPosition(textView: UITextView) { guard let index = cursorIndex, let newPosition = textView.position(from: textView.beginningOfDocument, offset: index) else { @@ -64,38 +74,27 @@ struct TextViewWrapper: UIViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention, updateCursorPosition: updateCursorPosition, onCaretRectChange: onCaretRectChange, textHeight: $textHeight) + Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention, updateCursorPosition: updateCursorPosition) } class Coordinator: NSObject, UITextViewDelegate { @Binding var attributedText: NSMutableAttributedString var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil let updateCursorPosition: ((Int) -> Void) - let onCaretRectChange: ((UITextView) -> Void) - @Binding var textHeight: CGFloat? init(attributedText: Binding, getFocusWordForMention: ((String?, NSRange?) -> Void)?, - updateCursorPosition: @escaping ((Int) -> Void), - onCaretRectChange: @escaping ((UITextView) -> Void), - textHeight: Binding + updateCursorPosition: @escaping ((Int) -> Void) ) { _attributedText = attributedText self.getFocusWordForMention = getFocusWordForMention self.updateCursorPosition = updateCursorPosition - self.onCaretRectChange = onCaretRectChange - _textHeight = textHeight } func textViewDidChange(_ textView: UITextView) { attributedText = NSMutableAttributedString(attributedString: textView.attributedText) processFocusedWordForMention(textView: textView) } - - func textViewDidChangeSelection(_ textView: UITextView) { - textView.scrollRangeToVisible(textView.selectedRange) - onCaretRectChange(textView) - } private func processFocusedWordForMention(textView: UITextView) { var val: (String?, NSRange?) = (nil, nil) @@ -183,20 +182,6 @@ struct TextViewWrapper: UIViewRepresentable { } } - override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { - if keyPath == "contentSize", let textView = object as? UITextView { - DispatchQueue.main.async { - // Update text view height when text content size changes to fit all text content - // This is necessary to avoid having a scrolling text box combined with its parent scrolling view - self.updateTextViewHeight(textView: textView) - } - } - } - - func updateTextViewHeight(textView: UITextView) { - self.textHeight = textView.contentSize.height + TEXT_BOX_BOTTOM_MARGIN_OFFSET - } - } }