diff --git a/ListableUI/Sources/Behavior.swift b/ListableUI/Sources/Behavior.swift index 02415906..5c5b0546 100644 --- a/ListableUI/Sources/Behavior.swift +++ b/ListableUI/Sources/Behavior.swift @@ -21,6 +21,12 @@ public struct Behavior : Equatable /// How to adjust the `contentInset` of the list when the keyboard visibility changes. public var keyboardAdjustmentMode : KeyboardAdjustmentMode + + /// Additional insets to apply while adjusting the list for an overlapping keyboard. + /// + /// This is useful for persistent overlays, such as floating action bars, that should be treated + /// as unavailable space when UIKit scrolls the first responder into view. + public var keyboardAdjustmentAdditionalInsets : UIEdgeInsets /// How the list should react when the user taps the application status bar. /// The default value of this enables scrolling to top. @@ -64,6 +70,7 @@ public struct Behavior : Equatable isScrollEnabled: Bool = true, keyboardDismissMode : UIScrollView.KeyboardDismissMode = .interactive, keyboardAdjustmentMode : KeyboardAdjustmentMode = .adjustsWhenVisible, + keyboardAdjustmentAdditionalInsets : UIEdgeInsets = .zero, scrollsToTop : ScrollsToTop = .enabled, selectionMode : SelectionMode = .single, underflow : Underflow = Underflow(), @@ -77,6 +84,7 @@ public struct Behavior : Equatable self.isScrollEnabled = isScrollEnabled self.keyboardDismissMode = keyboardDismissMode self.keyboardAdjustmentMode = keyboardAdjustmentMode + self.keyboardAdjustmentAdditionalInsets = keyboardAdjustmentAdditionalInsets self.scrollsToTop = scrollsToTop diff --git a/ListableUI/Sources/ListView/ListView.swift b/ListableUI/Sources/ListView/ListView.swift index 651b6b30..b53f8c0f 100644 --- a/ListableUI/Sources/ListView/ListView.swift +++ b/ListableUI/Sources/ListView/ListView.swift @@ -127,6 +127,13 @@ public final class ListView : UIView name: UITextField.textDidBeginEditingNotification, object: nil ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidBeginEditingNotification(_:)), + name: UITextView.textDidBeginEditingNotification, + object: nil + ) NotificationCenter.default.addObserver( self, @@ -134,6 +141,13 @@ public final class ListView : UIView name: UITextField.textDidEndEditingNotification, object: nil ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidEndEditingNotification(_:)), + name: UITextView.textDidEndEditingNotification, + object: nil + ) } deinit @@ -416,6 +430,9 @@ public final class ListView : UIView /// whenever insets require an update. public func updateScrollViewInsets() { + let previousContentInset = self.collectionView.contentInset + let previousAdjustedContentInset = self.collectionView.adjustedContentInset + let insets: ScrollViewInsets if case .custom = self.behavior.keyboardAdjustmentMode { insets = self.customScrollViewInsets() @@ -436,6 +453,16 @@ public final class ListView : UIView if self.collectionView.verticalScrollIndicatorInsets != insets.verticalScroll { self.collectionView.verticalScrollIndicatorInsets = insets.verticalScroll } + + let nextAdjustedContentInset = self.collectionView.adjustedContentInset + let didChangeInsets = + previousContentInset != self.collectionView.contentInset || + previousAdjustedContentInset != nextAdjustedContentInset + + if didChangeInsets { + self.collectionViewLayout.setNeedsRelayout() + self.collectionView.layoutIfNeeded() + } } func calculateScrollViewInsets(with keyboardFrame : KeyboardFrame?) -> ScrollViewInsets { @@ -467,12 +494,19 @@ public final class ListView : UIView } }() + let keyboardAdjustmentAdditionalInsets: UIEdgeInsets = keyboardBottomInset > 0.0 + ? self.behavior.keyboardAdjustmentAdditionalInsets + : .zero + let scrollInsets = modified(self.scrollIndicatorInsets) { - $0.bottom = max($0.bottom, keyboardBottomInset) + $0.bottom = max($0.bottom, keyboardBottomInset + keyboardAdjustmentAdditionalInsets.bottom) } let contentInsets = modified(self.collectionView.contentInset) { - $0.bottom = keyboardBottomInset + $0.top = keyboardAdjustmentAdditionalInsets.top + $0.left = keyboardAdjustmentAdditionalInsets.left + $0.bottom = keyboardBottomInset + keyboardAdjustmentAdditionalInsets.bottom + $0.right = keyboardAdjustmentAdditionalInsets.right } return .init( @@ -659,7 +693,6 @@ public final class ListView : UIView completion: ScrollCompletion? = nil ) -> Bool { - let storageContent = storage.allContent // Make sure the section identifier is valid. @@ -1129,9 +1162,9 @@ public final class ListView : UIView override public func layoutSubviews() { super.layoutSubviews() - + self.collectionView.frame = self.bounds - + /// Our layout changed, update the keyboard inset in case the inset should now be different. self.updateScrollViewInsets() } @@ -1996,6 +2029,14 @@ final class CollectionView : ListView.IOS16_4_First_Responder_Bug_CollectionView } } + override func scrollRectToVisible(_ rect: CGRect, animated: Bool) { + // UIKit can ask for a non-animated focus scroll from inside the keyboard animation + // transaction. Promote that case to the scroll view's animated path so cells move + // through UICollectionView's normal scrolling lifecycle instead of jumping mid-transaction. + let shouldPromoteToScrollAnimation = !animated && UIView.inheritedAnimationDuration > 0 + super.scrollRectToVisible(rect, animated: animated || shouldPromoteToScrollAnimation) + } + /// Returns true when the content size is large enough that scrolling is possible /// without bouncing back to it's original position. var isContentScrollable: Bool { diff --git a/ListableUI/Tests/BehaviorTests.swift b/ListableUI/Tests/BehaviorTests.swift index 2189562c..99703dc7 100644 --- a/ListableUI/Tests/BehaviorTests.swift +++ b/ListableUI/Tests/BehaviorTests.swift @@ -18,6 +18,7 @@ class BehaviorTests: XCTestCase XCTAssertEqual(behavior.keyboardDismissMode, .interactive) XCTAssertEqual(behavior.keyboardAdjustmentMode, .adjustsWhenVisible) + XCTAssertEqual(behavior.keyboardAdjustmentAdditionalInsets, .zero) XCTAssertEqual(behavior.selectionMode, .single) diff --git a/ListableUI/Tests/ListView/ListViewTests.swift b/ListableUI/Tests/ListView/ListViewTests.swift index 4846e55d..14d08474 100644 --- a/ListableUI/Tests/ListView/ListViewTests.swift +++ b/ListableUI/Tests/ListView/ListViewTests.swift @@ -157,6 +157,43 @@ class ListViewTests: XCTestCase UIEdgeInsets(top: 10, left: 0, bottom: 200, right: 0) ) } + + self.testcase("Overlapping Keyboard Frame With Additional Insets") { + listView.behavior.keyboardAdjustmentAdditionalInsets = UIEdgeInsets(top: 1, left: 2, bottom: 50, right: 4) + + let insets = listView.calculateScrollViewInsets( + with:.overlapping(frame: CGRect(x: 0, y: 200, width: 200, height: 200)) + ) + + XCTAssertEqual( + insets.content, + UIEdgeInsets(top: 1, left: 2, bottom: 250, right: 4) + ) + + XCTAssertEqual( + insets.horizontalScroll, + UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 40) + ) + + XCTAssertEqual( + insets.verticalScroll, + UIEdgeInsets(top: 10, left: 0, bottom: 250, right: 0) + ) + } + + self.testcase("Non-Overlapping Keyboard Frame With Additional Insets") { + let insets = listView.calculateScrollViewInsets(with: .nonOverlapping) + + XCTAssertEqual( + insets.content, + UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + ) + + XCTAssertEqual( + insets.verticalScroll, + UIEdgeInsets(top: 10, left: 0, bottom: 30, right: 0) + ) + } } func test_change_size() {