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:
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
+ }
+
}
}