Scroll attachment description views to ensure caret is always visible

This commit is contained in:
Shadowfacts 2020-11-11 12:24:00 -05:00
parent 80cca7673a
commit 366378f267
Signed by untrusted user: shadowfacts
GPG Key ID: 94A5AB95422746E5
5 changed files with 72 additions and 51 deletions

View File

@ -214,6 +214,7 @@
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */; }; D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */; };
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */; }; D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */; };
D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */; }; D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */; };
D6A57408255C53EC00674551 /* ComposeTextViewCaretScrolling.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */; };
D6A5BB2B23BAEF61003BF21D /* APIMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */; }; D6A5BB2B23BAEF61003BF21D /* APIMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */; };
D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */; }; D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */; };
D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */; }; D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */; };
@ -562,6 +563,7 @@
D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastAccountSwitcherViewController.swift; sourceTree = "<group>"; }; D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastAccountSwitcherViewController.swift; sourceTree = "<group>"; };
D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FastAccountSwitcherViewController.xib; sourceTree = "<group>"; }; D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FastAccountSwitcherViewController.xib; sourceTree = "<group>"; };
D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastSwitchingAccountView.swift; sourceTree = "<group>"; }; D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastSwitchingAccountView.swift; sourceTree = "<group>"; };
D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextViewCaretScrolling.swift; sourceTree = "<group>"; };
D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIMocks.swift; sourceTree = "<group>"; }; D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIMocks.swift; sourceTree = "<group>"; };
D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = "<group>"; }; D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = "<group>"; };
D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = "<group>"; }; D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = "<group>"; };
@ -1027,6 +1029,7 @@
D677284724ECBCB100C732D3 /* ComposeView.swift */, D677284724ECBCB100C732D3 /* ComposeView.swift */,
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */, D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */,
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */, D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */,
D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */,
D62275A924F1E01C00B82A16 /* ComposeTextView.swift */, D62275A924F1E01C00B82A16 /* ComposeTextView.swift */,
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */, D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */,
D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */, D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */,
@ -1861,6 +1864,7 @@
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */, D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
D620483623D38075008A63EF /* ContentTextView.swift in Sources */, D620483623D38075008A63EF /* ContentTextView.swift in Sources */,
D6F2E965249E8BFD005846BB /* CrashReporterViewController.swift in Sources */, D6F2E965249E8BFD005846BB /* CrashReporterViewController.swift in Sources */,
D6A57408255C53EC00674551 /* ComposeTextViewCaretScrolling.swift in Sources */,
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */, D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */,
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */, D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */, D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,

View File

@ -112,6 +112,7 @@ struct ComposeAttachmentsList: View {
let proxy = UITableView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self]) let proxy = UITableView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self])
// enable drag and drop to reorder on iPhone // enable drag and drop to reorder on iPhone
proxy.dragInteractionEnabled = true proxy.dragInteractionEnabled = true
proxy.isScrollEnabled = false
} }
private func attachmentsChanged(attachments: [CompositionAttachment]) { private func attachmentsChanged(attachments: [CompositionAttachment]) {

View File

@ -101,9 +101,10 @@ struct WrappedTextView: UIViewRepresentable {
return Coordinator(text: $text, didChange: textDidChange) return Coordinator(text: $text, didChange: textDidChange)
} }
class Coordinator: NSObject, UITextViewDelegate { class Coordinator: NSObject, UITextViewDelegate, ComposeTextViewCaretScrolling {
var text: Binding<String> var text: Binding<String>
var didChange: ((UITextView) -> Void)? var didChange: ((UITextView) -> Void)?
var caretScrollPositionAnimator: UIViewPropertyAnimator?
init(text: Binding<String>, didChange: ((UITextView) -> Void)?) { init(text: Binding<String>, didChange: ((UITextView) -> Void)?) {
self.text = text self.text = text
@ -113,6 +114,8 @@ struct WrappedTextView: UIViewRepresentable {
func textViewDidChange(_ textView: UITextView) { func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.text text.wrappedValue = textView.text
didChange?(textView) didChange?(textView)
ensureCursorVisible(textView: textView)
} }
} }
} }

View File

@ -0,0 +1,60 @@
//
// ComposeTextViewCaretScrolling.swift
// Tusker
//
// Created by Shadowfacts on 11/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
protocol ComposeTextViewCaretScrolling: class {
var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set }
}
extension ComposeTextViewCaretScrolling {
func ensureCursorVisible(textView: UITextView) {
guard textView.isFirstResponder,
let range = textView.selectedTextRange,
let scrollView = findParentScrollView(of: textView) else {
return
}
// We use a UIViewProperty animator to change the scroll view position so that we can store the currently
// running one on the Coordinator. This allows us to cancel the running one, preventing multiple animations
// from attempting to change the scroll view offset simultaneously, causing it to jitter around. This can
// happen if the user is pressing return and quickly creating many new lines.
if let existing = caretScrollPositionAnimator {
existing.stopAnimation(true)
}
let cursorRect = textView.caretRect(for: range.start)
var rectToMakeVisible = textView.convert(cursorRect, to: scrollView)
// expand the rect to be three times the cursor height centered on the cursor so that there's
// some space between the bottom of the line of text being edited and the top of the keyboard
rectToMakeVisible.origin.y -= cursorRect.height
rectToMakeVisible.size.height *= 3
let animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
}
self.caretScrollPositionAnimator = animator
animator.startAnimation()
}
private func findParentScrollView(of view: UIView) -> UIScrollView? {
var current: UIView = view
while let superview = current.superview {
if let scrollView = superview as? UIScrollView,
scrollView.isScrollEnabled {
return scrollView
} else {
current = superview
}
}
return nil
}
}

View File

@ -171,12 +171,12 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
return Coordinator(text: $text, uiState: uiState, didChange: textDidChange) return Coordinator(text: $text, uiState: uiState, didChange: textDidChange)
} }
class Coordinator: NSObject, UITextViewDelegate, ComposeAutocompleteHandler { class Coordinator: NSObject, UITextViewDelegate, ComposeAutocompleteHandler, ComposeTextViewCaretScrolling {
weak var textView: UITextView? weak var textView: UITextView?
var text: Binding<String> var text: Binding<String>
var didChange: (UITextView) -> Void var didChange: (UITextView) -> Void
var uiState: ComposeUIState var uiState: ComposeUIState
private var caretScrollPositionAnimator: UIViewPropertyAnimator? var caretScrollPositionAnimator: UIViewPropertyAnimator?
init(text: Binding<String>, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) { init(text: Binding<String>, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) {
self.text = text self.text = text
@ -188,54 +188,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
text.wrappedValue = textView.text text.wrappedValue = textView.text
didChange(textView) didChange(textView)
ensureCursorVisible() ensureCursorVisible(textView: textView)
}
private func ensureCursorVisible() {
guard let textView = textView,
textView.isFirstResponder,
let range = textView.selectedTextRange,
let scrollView = findParentScrollView() else {
return
}
// We use a UIViewProperty animator to change the scroll view position so that we can store the currently
// running one on the Coordinator. This allows us to cancel the running one, preventing multiple animations
// from attempting to change the scroll view offset simultaneously, causing it to jitter around. This can
// happen if the user is pressing return and quickly creating many new lines.
if let existing = caretScrollPositionAnimator {
existing.stopAnimation(true)
}
let cursorRect = textView.caretRect(for: range.start)
var rectToMakeVisible = textView.convert(cursorRect, to: scrollView)
// expand the rect to be three times the cursor height centered on the cursor so that there's
// some space between the bottom of the line of text being edited and the top of the keyboard
rectToMakeVisible.origin.y -= cursorRect.height
rectToMakeVisible.size.height *= 3
let animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
}
self.caretScrollPositionAnimator = animator
animator.startAnimation()
}
private func findParentScrollView() -> UIScrollView? {
guard let textView = textView else { return nil }
var current: UIView = textView
while let superview = current.superview {
if let scrollView = superview as? UIScrollView {
return scrollView
} else {
current = superview
}
}
return nil
} }
@objc func formatButtonPressed(_ sender: UIBarButtonItem) { @objc func formatButtonPressed(_ sender: UIBarButtonItem) {