Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ private struct ComposerTokenPayload: Decodable {
let end: Int
}

private struct ComposerSelectionPayload: Decodable {
private struct ComposerSelectionPayload: Decodable, Equatable {
let start: Int
let end: Int
}
Expand Down Expand Up @@ -449,6 +449,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
private var tokensNeedRebuild = false
private var chipsNeedMeasuredWidth = false
private var hasDeferredForcedRebuild = false
private var pendingContentOffset: CGPoint?
private var needsSelectionReveal = false
private var selectionBeforeFocusingTap: (selection: ComposerSelectionPayload, value: String)?

let onComposerChange = EventDispatcher()
let onComposerSelectionChange = EventDispatcher()
Expand Down Expand Up @@ -520,6 +523,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
@objc private func openContext(_ recognizer: UITapGestureRecognizer) {
guard let (index, attachment) = contextAttachment(at: recognizer.location(in: textView)) else {
textView.becomeFirstResponder()
if selectionBeforeFocusingTap != nil {
// UIKit places the caret from its own tap recognizer during this same touch.
DispatchQueue.main.async { [weak self] in
self?.restoreSelectionBeforeFocusingTap()
}
}
return
}
openContext(index: index, attachment: attachment)
Expand Down Expand Up @@ -558,7 +567,11 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
}

public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
true
// The collapsed editor only shows the draft's first line, so a tap that expands an
// overflowing draft returns to the caret it was left at instead of moving it there.
let collapsedOverflow = !textView.isFirstResponder && !textView.isScrollEnabled && draftOverflowsBounds()
selectionBeforeFocusingTap = collapsedOverflow ? (sourceSelection(), value) : nil
return true
}

public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
Expand All @@ -583,11 +596,27 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro

public override func layoutSubviews() {
super.layoutSubviews()
let previousSize = textView.bounds.size
textView.frame = bounds
if chipsNeedMeasuredWidth, bounds.width > 0 {
chipsNeedMeasuredWidth = false
applyControlledDocument(force: true)
}
if textView.isScrollEnabled, bounds.width > 0, bounds.height > 0 {
textView.layoutIfNeeded()
if let offset = pendingContentOffset {
let minY = -textView.adjustedContentInset.top
let maxY = max(minY, textView.contentSize.height - bounds.height + textView.adjustedContentInset.bottom)
textView.setContentOffset(CGPoint(x: offset.x, y: min(maxY, max(minY, offset.y))), animated: false)
pendingContentOffset = nil
}
if needsSelectionReveal || (previousSize != bounds.size && textView.isFirstResponder) {
needsSelectionReveal = false
if let selection = textView.selectedTextRange {
textView.scrollRectToVisible(textView.caretRect(for: selection.end), animated: false)
}
}
}
let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding
let placeholderY = textView.textContainerInset.top
let placeholderWidth = max(
Expand Down Expand Up @@ -632,10 +661,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
tokens = decode([ComposerTokenPayload].self, from: document.tokensJson) ?? []
tokensNeedRebuild = true
}
let previousSelection = sourceSelection()
value = document.value
requestedSelection = document.selection
applyControlledDocument(force: tokensNeedRebuild)
applyRequestedSelection()
cancelFocusingTapRestoreIfSelectionMoved(from: previousSelection)
if tokensMatchCurrentValue() {
tokensNeedRebuild = false
}
Expand Down Expand Up @@ -694,7 +725,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
}

func setScrollEnabled(_ scrollEnabled: Bool) {
guard textView.isScrollEnabled != scrollEnabled else { return }
if !scrollEnabled {
// Disabling scrolling discards UITextView's offset. Restore it after
// expansion has laid out the text, regardless of prop update order.
pendingContentOffset = pendingContentOffset ?? textView.contentOffset
}
textView.isScrollEnabled = scrollEnabled
if scrollEnabled {
revealSelectionAfterLayout()
}
}

func setAutoFocus(_ autoFocus: Bool) {
Expand Down Expand Up @@ -738,8 +778,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
}

func setSelection(start: Int, end: Int) {
let previousSelection = sourceSelection()
requestedSelection = ComposerSelectionPayload(start: start, end: end)
applyRequestedSelection()
cancelFocusingTapRestoreIfSelectionMoved(from: previousSelection)
}

public func textViewDidChange(_ textView: UITextView) {
Expand Down Expand Up @@ -811,6 +853,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
}

public func textViewDidBeginEditing(_ textView: UITextView) {
revealSelectionAfterLayout()
onComposerFocus()
}

Expand All @@ -836,13 +879,21 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
hasDeferredForcedRebuild = false

let previousSelection = sourceSelection()
if textView.isScrollEnabled {
pendingContentOffset = pendingContentOffset ?? textView.contentOffset
}
isApplyingControlledValue = true
textView.attributedText = makeAttributedDocument()
let targetSelection = requestedSelection ?? previousSelection
requestedSelection = nil
textView.selectedRange = displayRange(for: targetSelection)
restoreBaseTypingAttributes()
isApplyingControlledValue = false
if currentSource != value || previousSelection.start != targetSelection.start || previousSelection.end != targetSelection.end {
revealSelectionAfterLayout()
} else {
setNeedsLayout()
}
updatePlaceholderVisibility()
emitContentSizeIfNeeded()
}
Expand Down Expand Up @@ -1053,6 +1104,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
return
}
value = textView.serializedText()
revealSelectionAfterLayout()
let selection = sourceSelection()
nativeEventCount += 1
onComposerChange([
Expand Down Expand Up @@ -1122,6 +1174,40 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
isApplyingControlledValue = true
textView.selectedRange = nextRange
isApplyingControlledValue = false
revealSelectionAfterLayout()
}

private func restoreSelectionBeforeFocusingTap() {
guard let snapshot = selectionBeforeFocusingTap else {
return
}
selectionBeforeFocusingTap = nil
// A draft that changed during the touch, such as a transcript landing, owns the caret now.
guard textView.isFirstResponder, textView.markedTextRange == nil, value == snapshot.value else {
return
}
let range = displayRange(for: snapshot.selection)
guard !NSEqualRanges(range, textView.selectedRange) else {
return
}
textView.selectedRange = range
revealSelectionAfterLayout()
}

private func cancelFocusingTapRestoreIfSelectionMoved(from previousSelection: ComposerSelectionPayload) {
if sourceSelection() != previousSelection {
selectionBeforeFocusingTap = nil
}
}

private func draftOverflowsBounds() -> Bool {
let size = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude))
return size.height > textView.bounds.height + 1
}

private func revealSelectionAfterLayout() {
needsSelectionReveal = true
setNeedsLayout()
}

private func updatePlaceholderVisibility() {
Expand Down
Loading