damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 01b8e43a6e4af48b591666631f588c6dc64205f3
parent aa4ecc213917a9088963e0d6ba719b54bccffbe8
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Sat, 16 Sep 2023 03:32:56 +0000

compose: fix text wrapping issue when mentioning npub

Closes: https://github.com/damus-io/damus/issues/1211
Changelog-Fixed: Fix text composer wrapping issue when mentioning npub
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mdamus/Views/PostView.swift | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mdamus/Views/Posting/UserSearch.swift | 12++----------
Mdamus/Views/TextViewWrapper.swift | 51+++++++++++++++++++++++++++++++++++++++++++++------
3 files changed, 124 insertions(+), 38 deletions(-)

diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift @@ -14,6 +14,8 @@ enum NostrPostResult { } let POST_PLACEHOLDER = NSLocalizedString("Type your note here...", comment: "Text box prompt to ask user to type their note.") +let GHOST_CARET_VIEW_ID = "GhostCaret" +let DEBUG_SHOW_GHOST_CARET_VIEW: Bool = false class TagModel: ObservableObject { var diff = 0 @@ -54,7 +56,8 @@ struct PostView: View { @State var filtered_pubkeys: Set<Pubkey> = [] @State var focusWordAttributes: (String?, NSRange?) = (nil, nil) @State var newCursorIndex: Int? - @State var postTextViewCanScroll: Bool = true + @State var caretRect: CGRect = CGRectNull + @State var textHeight: CGFloat? = nil @State var mediaToUpload: MediaUpload? = nil @@ -104,6 +107,16 @@ struct PostView: View { return is_post_empty || uploading_disabled } + // Returns a valid height for the text box, even when textHeight is not a number + func get_valid_text_height() -> CGFloat { + if let textHeight, textHeight.isFinite, textHeight > 0 { + return textHeight + } + else { + return 10 + } + } + var ImageButton: some View { Button(action: { attach_media = true @@ -201,11 +214,18 @@ struct PostView: View { var TextEntry: some View { ZStack(alignment: .topLeading) { - TextViewWrapper(attributedText: $post, postTextViewCanScroll: $postTextViewCanScroll, cursorIndex: newCursorIndex, getFocusWordForMention: { word, range in + TextViewWrapper(attributedText: $post, textHeight: $textHeight, cursorIndex: newCursorIndex, getFocusWordForMention: { word, range in focusWordAttributes = (word, range) 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) @@ -213,6 +233,8 @@ struct PostView: View { .onChange(of: post) { p in post_changed(post: p, media: uploadedMedias) } + // Set a height based on the text content height, if it is available and valid + .frame(height: get_valid_text_height()) if post.string.isEmpty { Text(POST_PLACEHOLDER) @@ -292,25 +314,48 @@ struct PostView: View { } func Editor(deviceSize: GeometryProxy) -> some View { - 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) - - TextEntry + HStack(alignment: .top, spacing: 0) { + if(caretRect != CGRectNull) { + GhostCaret } - .frame(height: deviceSize.size.height * multiply_factor) - .id("post") + 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) + + TextEntry + } + .id("post") + + PVImageCarouselView(media: $uploadedMedias, deviceWidth: deviceSize.size.width) + .onChange(of: uploadedMedias) { media in + post_changed(post: post, media: media) + } - PVImageCarouselView(media: $uploadedMedias, deviceWidth: deviceSize.size.width) - .onChange(of: uploadedMedias) { media in - post_changed(post: post, media: media) + if case .quoting(let ev) = action { + BuilderEventView(damus: damus_state, event: ev) } - - if case .quoting(let ev) = action { - BuilderEventView(damus: damus_state, event: ev) } + .padding(.horizontal) } - .padding(.horizontal) + } + + // 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) { @@ -332,26 +377,36 @@ struct PostView: View { GeometryReader { (deviceSize: GeometryProxy) in VStack(alignment: .leading, spacing: 0) { let searching = get_searching_string(focusWordAttributes.0) + let searchingIsNil = searching == nil TopBar ScrollViewReader { scroller in ScrollView { - if case .replying_to(let replying_to) = self.action { - ReplyView(replying_to: replying_to, damus: damus_state, original_pubkeys: pubkeys, filtered_pubkeys: $filtered_pubkeys) + VStack(alignment: .leading) { + if case .replying_to(let replying_to) = self.action { + ReplyView(replying_to: replying_to, damus: damus_state, original_pubkeys: pubkeys, filtered_pubkeys: $filtered_pubkeys) + } + + Editor(deviceSize: deviceSize) } - - Editor(deviceSize: deviceSize) } - .frame(maxHeight: searching == nil ? .infinity : 70) + .frame(maxHeight: searching == nil ? deviceSize.size.height : 70) .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 if let searching { - UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, postTextViewCanScroll: $postTextViewCanScroll, post: $post) + UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, post: $post) .frame(maxHeight: .infinity) .environmentObject(tagModel) } else { diff --git a/damus/Views/Posting/UserSearch.swift b/damus/Views/Posting/UserSearch.swift @@ -21,7 +21,6 @@ struct UserSearch: View { let search: String @Binding var focusWordAttributes: (String?, NSRange?) @Binding var newCursorIndex: Int? - @Binding var postTextViewCanScroll: Bool @Binding var post: NSMutableAttributedString @EnvironmentObject var tagModel: TagModel @@ -70,12 +69,6 @@ struct UserSearch: View { .padding() } } - .onAppear() { - postTextViewCanScroll = false - } - .onDisappear() { - postTextViewCanScroll = true - } } } @@ -85,10 +78,9 @@ struct UserSearch_Previews: PreviewProvider { @State static var post: NSMutableAttributedString = NSMutableAttributedString(string: "some @jb55") @State static var word: (String?, NSRange?) = (nil, nil) @State static var newCursorIndex: Int? - @State static var postTextViewCanScroll: Bool = false - + static var previews: some View { - UserSearch(damus_state: test_damus_state(), search: search, focusWordAttributes: $word, newCursorIndex: $newCursorIndex, postTextViewCanScroll: $postTextViewCanScroll, post: $post) + UserSearch(damus_state: test_damus_state(), search: search, focusWordAttributes: $word, newCursorIndex: $newCursorIndex, post: $post) } } diff --git a/damus/Views/TextViewWrapper.swift b/damus/Views/TextViewWrapper.swift @@ -7,20 +7,32 @@ 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 - @Binding var postTextViewCanScroll: Bool @EnvironmentObject var tagModel: TagModel + @Binding var textHeight: CGFloat? 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 - textView.isScrollEnabled = postTextViewCanScroll + + // 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 + TextViewWrapper.setTextProperties(textView) return textView } @@ -34,7 +46,6 @@ struct TextViewWrapper: UIViewRepresentable { } func updateUIView(_ uiView: UITextView, context: Context) { - uiView.isScrollEnabled = postTextViewCanScroll uiView.attributedText = attributedText TextViewWrapper.setTextProperties(uiView) @@ -53,24 +64,38 @@ struct TextViewWrapper: UIViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention, updateCursorPosition: updateCursorPosition) + Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention, updateCursorPosition: updateCursorPosition, onCaretRectChange: onCaretRectChange, textHeight: $textHeight) } class Coordinator: NSObject, UITextViewDelegate { @Binding var attributedText: NSMutableAttributedString var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil let updateCursorPosition: ((Int) -> Void) - - init(attributedText: Binding<NSMutableAttributedString>, getFocusWordForMention: ((String?, NSRange?) -> Void)?, updateCursorPosition: @escaping ((Int) -> Void)) { + let onCaretRectChange: ((UITextView) -> Void) + @Binding var textHeight: CGFloat? + + init(attributedText: Binding<NSMutableAttributedString>, + getFocusWordForMention: ((String?, NSRange?) -> Void)?, + updateCursorPosition: @escaping ((Int) -> Void), + onCaretRectChange: @escaping ((UITextView) -> Void), + textHeight: Binding<CGFloat?> + ) { _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) @@ -158,6 +183,20 @@ 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 + } + } }