Skip to content

Commit

Permalink
compose: fix text wrapping issue when mentioning npub
Browse files Browse the repository at this point in the history
Closes: #1211
Changelog-Fixed: Fix text composer wrapping issue when mentioning npub
Signed-off-by: Daniel D’Aquino <[email protected]>
Signed-off-by: William Casarin <[email protected]>
  • Loading branch information
danieldaquino authored and jb55 committed Sep 16, 2023
1 parent aa4ecc2 commit 01b8e43
Show file tree
Hide file tree
Showing 3 changed files with 124 additions and 38 deletions.
99 changes: 77 additions & 22 deletions damus/Views/PostView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -201,18 +214,27 @@ 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)
.textInputAutocapitalization(.sentences)
.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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
12 changes: 2 additions & 10 deletions damus/Views/Posting/UserSearch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,12 +69,6 @@ struct UserSearch: View {
.padding()
}
}
.onAppear() {
postTextViewCanScroll = false
}
.onDisappear() {
postTextViewCanScroll = true
}
}

}
Expand All @@ -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)
}
}

Expand Down
51 changes: 45 additions & 6 deletions damus/Views/TextViewWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -34,7 +46,6 @@ struct TextViewWrapper: UIViewRepresentable {
}

func updateUIView(_ uiView: UITextView, context: Context) {
uiView.isScrollEnabled = postTextViewCanScroll
uiView.attributedText = attributedText

TextViewWrapper.setTextProperties(uiView)
Expand All @@ -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)
Expand Down Expand Up @@ -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
}

}
}

0 comments on commit 01b8e43

Please sign in to comment.