From 366378f267d64a892bf9d537314c08932a6f70b4 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Wed, 11 Nov 2020 12:24:00 -0500 Subject: [PATCH] Scroll attachment description views to ensure caret is always visible --- Tusker.xcodeproj/project.pbxproj | 4 ++ .../Compose/ComposeAttachmentsList.swift | 1 + Tusker/Screens/Compose/ComposeTextView.swift | 5 +- .../ComposeTextViewCaretScrolling.swift | 60 +++++++++++++++++++ .../Screens/Compose/MainComposeTextView.swift | 53 +--------------- 5 files changed, 72 insertions(+), 51 deletions(-) create mode 100644 Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 69806303..774cc56c 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -214,6 +214,7 @@ D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */; }; D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */; }; 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 */; }; D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.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 = ""; }; D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FastAccountSwitcherViewController.xib; sourceTree = ""; }; D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastSwitchingAccountView.swift; sourceTree = ""; }; + D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextViewCaretScrolling.swift; sourceTree = ""; }; D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIMocks.swift; sourceTree = ""; }; D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = ""; }; D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = ""; }; @@ -1027,6 +1029,7 @@ D677284724ECBCB100C732D3 /* ComposeView.swift */, D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */, D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */, + D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */, D62275A924F1E01C00B82A16 /* ComposeTextView.swift */, D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */, D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */, @@ -1861,6 +1864,7 @@ D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */, D620483623D38075008A63EF /* ContentTextView.swift in Sources */, D6F2E965249E8BFD005846BB /* CrashReporterViewController.swift in Sources */, + D6A57408255C53EC00674551 /* ComposeTextViewCaretScrolling.swift in Sources */, D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */, D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */, D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */, diff --git a/Tusker/Screens/Compose/ComposeAttachmentsList.swift b/Tusker/Screens/Compose/ComposeAttachmentsList.swift index f63ae1eb..a6fa03ea 100644 --- a/Tusker/Screens/Compose/ComposeAttachmentsList.swift +++ b/Tusker/Screens/Compose/ComposeAttachmentsList.swift @@ -112,6 +112,7 @@ struct ComposeAttachmentsList: View { let proxy = UITableView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self]) // enable drag and drop to reorder on iPhone proxy.dragInteractionEnabled = true + proxy.isScrollEnabled = false } private func attachmentsChanged(attachments: [CompositionAttachment]) { diff --git a/Tusker/Screens/Compose/ComposeTextView.swift b/Tusker/Screens/Compose/ComposeTextView.swift index b56e08ac..1d2d5f29 100644 --- a/Tusker/Screens/Compose/ComposeTextView.swift +++ b/Tusker/Screens/Compose/ComposeTextView.swift @@ -101,9 +101,10 @@ struct WrappedTextView: UIViewRepresentable { return Coordinator(text: $text, didChange: textDidChange) } - class Coordinator: NSObject, UITextViewDelegate { + class Coordinator: NSObject, UITextViewDelegate, ComposeTextViewCaretScrolling { var text: Binding var didChange: ((UITextView) -> Void)? + var caretScrollPositionAnimator: UIViewPropertyAnimator? init(text: Binding, didChange: ((UITextView) -> Void)?) { self.text = text @@ -113,6 +114,8 @@ struct WrappedTextView: UIViewRepresentable { func textViewDidChange(_ textView: UITextView) { text.wrappedValue = textView.text didChange?(textView) + + ensureCursorVisible(textView: textView) } } } diff --git a/Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift b/Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift new file mode 100644 index 00000000..2a9cd0fe --- /dev/null +++ b/Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift @@ -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 + } +} diff --git a/Tusker/Screens/Compose/MainComposeTextView.swift b/Tusker/Screens/Compose/MainComposeTextView.swift index 6d0ba707..75988099 100644 --- a/Tusker/Screens/Compose/MainComposeTextView.swift +++ b/Tusker/Screens/Compose/MainComposeTextView.swift @@ -171,12 +171,12 @@ struct MainComposeWrappedTextView: UIViewRepresentable { return Coordinator(text: $text, uiState: uiState, didChange: textDidChange) } - class Coordinator: NSObject, UITextViewDelegate, ComposeAutocompleteHandler { + class Coordinator: NSObject, UITextViewDelegate, ComposeAutocompleteHandler, ComposeTextViewCaretScrolling { weak var textView: UITextView? var text: Binding var didChange: (UITextView) -> Void var uiState: ComposeUIState - private var caretScrollPositionAnimator: UIViewPropertyAnimator? + var caretScrollPositionAnimator: UIViewPropertyAnimator? init(text: Binding, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) { self.text = text @@ -188,54 +188,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable { text.wrappedValue = textView.text didChange(textView) - ensureCursorVisible() - } - - 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 + ensureCursorVisible(textView: textView) } @objc func formatButtonPressed(_ sender: UIBarButtonItem) {