diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e39e8f1..511f6948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,16 @@ ### Added +- Added `Behavior.occlusionInsets` to account for persistent overlay UI that visually covers the list viewport. + ### Removed ### Changed ### Misc +- Added a development demo for validating keyboard avoidance with a floating bottom overlay and text fields. + ### Internal # Past Releases diff --git a/Development/Sources/Demos/Demo Screens/FloatingBottomKeyboardAvoidanceViewController.swift b/Development/Sources/Demos/Demo Screens/FloatingBottomKeyboardAvoidanceViewController.swift new file mode 100644 index 00000000..3c334ebf --- /dev/null +++ b/Development/Sources/Demos/Demo Screens/FloatingBottomKeyboardAvoidanceViewController.swift @@ -0,0 +1,204 @@ +// +// FloatingBottomKeyboardAvoidanceViewController.swift +// Demo +// +// Created by Rob MacEachern on 5/15/26. +// Copyright © 2026 Kyle Van Essen. All rights reserved. +// + +import BlueprintUI +import BlueprintUICommonControls +import BlueprintUILists +import ListableUI +import UIKit + +final class FloatingBottomKeyboardAvoidanceViewController: UIViewController { + private let listView = ListView() + private let bottomBarView = UIView() + private let bottomBarBlurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) + private let resignButton = UIButton(type: .system) + private let keyboardDismissModeControl = UISegmentedControl(items: ["None", "Drag", "Interactive"]) + + private let floatingBarHeight: CGFloat = 76.0 + private let bottomContentSpacing: CGFloat = 24.0 + private var keyboardDismissMode: UIScrollView.KeyboardDismissMode = .interactive + + override func loadView() { + view = UIView() + view.backgroundColor = .white + + listView.translatesAutoresizingMaskIntoConstraints = false + bottomBarView.translatesAutoresizingMaskIntoConstraints = false + bottomBarBlurView.translatesAutoresizingMaskIntoConstraints = false + resignButton.translatesAutoresizingMaskIntoConstraints = false + + bottomBarView.backgroundColor = .clear + bottomBarView.isOpaque = false + bottomBarBlurView.alpha = 0.72 + bottomBarBlurView.isUserInteractionEnabled = false + + resignButton.configuration = .filled() + resignButton.setTitle("Resign", for: .normal) + resignButton.addTarget(self, action: #selector(resignEditing), for: .touchUpInside) + + view.addSubview(listView) + view.addSubview(bottomBarView) + bottomBarView.addSubview(bottomBarBlurView) + bottomBarView.addSubview(resignButton) + + NSLayoutConstraint.activate([ + listView.topAnchor.constraint(equalTo: view.topAnchor), + listView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + listView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + listView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + bottomBarView.topAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -floatingBarHeight), + bottomBarView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bottomBarView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bottomBarView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + bottomBarBlurView.topAnchor.constraint(equalTo: bottomBarView.topAnchor), + bottomBarBlurView.leadingAnchor.constraint(equalTo: bottomBarView.leadingAnchor), + bottomBarBlurView.trailingAnchor.constraint(equalTo: bottomBarView.trailingAnchor), + bottomBarBlurView.bottomAnchor.constraint(equalTo: bottomBarView.bottomAnchor), + + resignButton.topAnchor.constraint(equalTo: bottomBarView.topAnchor, constant: 16.0), + resignButton.centerXAnchor.constraint(equalTo: bottomBarView.safeAreaLayoutGuide.centerXAnchor), + resignButton.leadingAnchor.constraint(greaterThanOrEqualTo: bottomBarView.safeAreaLayoutGuide.leadingAnchor, constant: 20.0), + resignButton.trailingAnchor.constraint(lessThanOrEqualTo: bottomBarView.safeAreaLayoutGuide.trailingAnchor, constant: -20.0), + resignButton.widthAnchor.constraint(equalToConstant: 180.0), + resignButton.heightAnchor.constraint(equalToConstant: 44.0), + ]) + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Floating Bottom Keyboard" + keyboardDismissModeControl.selectedSegmentIndex = 2 + keyboardDismissModeControl.addTarget(self, action: #selector(keyboardDismissModeChanged), for: .valueChanged) + navigationItem.titleView = keyboardDismissModeControl + + configureList() + } + + @objc private func keyboardDismissModeChanged() { + switch keyboardDismissModeControl.selectedSegmentIndex { + case 0: + keyboardDismissMode = .none + case 1: + keyboardDismissMode = .onDrag + default: + keyboardDismissMode = .interactive + } + + listView.behavior.keyboardDismissMode = keyboardDismissMode + } + + @objc private func resignEditing() { + view.endEditing(true) + } + + private func configureList() { + listView.configure { list in + list.layout = .table { layout in + layout.stickySectionHeaders = false + layout.bounds = .init( + padding: UIEdgeInsets(top: 16.0, left: 20.0, bottom: self.bottomContentSpacing, right: 20.0), + width: .atMost(600.0) + ) + layout.layout.itemSpacing = 8.0 + } + + list.behavior.keyboardDismissMode = self.keyboardDismissMode + list.behavior.keyboardAdjustmentMode = .adjustsWhenVisible + list.behavior.occlusionInsets = UIEdgeInsets( + top: 0.0, + left: 0.0, + bottom: self.floatingBarHeight, + right: 0.0 + ) + + list.add { + Section("inputs") { + Item( + TextFieldContent( + identifierValue: "first-field", + placeholder: "First field" + ), + sizing: .fixed(height: 56.0) + ) + + for index in 1 ... 11 { + Item(TextRowContent(text: "Filler row \(index)"), sizing: .fixed(height: 44.0)) + } + + Item( + TextFieldContent( + identifierValue: "middle-field", + placeholder: "Middle field" + ), + sizing: .fixed(height: 56.0) + ) + } header: { + SectionTitleContent(title: "Inputs") + } + + Section("more-content") { + for index in 12 ... 21 { + Item(TextRowContent(text: "Filler row \(index)"), sizing: .fixed(height: 44.0)) + } + + Item( + TextFieldContent( + identifierValue: "last-field", + placeholder: "Last field" + ), + sizing: .fixed(height: 56.0) + ) + } header: { + SectionTitleContent(title: "More Content") + } + } + } + } +} + +private struct SectionTitleContent: BlueprintHeaderFooterContent, Equatable { + var title: String + + var elementRepresentation: Element { + Label(text: title) { + $0.font = .systemFont(ofSize: 20.0, weight: .semibold) + $0.color = .black + } + .inset(by: UIEdgeInsets(top: 16.0, left: 0.0, bottom: 4.0, right: 0.0)) + } +} + +private struct TextRowContent: BlueprintItemContent, Equatable { + var text: String + + var identifierValue: String { + text + } + + func element(with _: ApplyItemContentInfo) -> Element { + Label(text: text) { + $0.font = .systemFont(ofSize: 17.0) + $0.color = .darkGray + } + } +} + +private struct TextFieldContent: BlueprintItemContent, Equatable { + var identifierValue: String + var placeholder: String + + func element(with _: ApplyItemContentInfo) -> Element { + TextField(text: "") { + $0.placeholder = placeholder + } + .inset(vertical: 8.0) + } +} diff --git a/Development/Sources/Demos/DemosRootViewController.swift b/Development/Sources/Demos/DemosRootViewController.swift index 272a8ef7..46743521 100644 --- a/Development/Sources/Demos/DemosRootViewController.swift +++ b/Development/Sources/Demos/DemosRootViewController.swift @@ -138,6 +138,14 @@ public final class DemosRootViewController : ListViewController } ) + Item( + DemoItem(text: "Keyboard Inset (Floating Bottom)"), + selectionStyle: .selectable(), + onSelect : { _ in + self?.push(FloatingBottomKeyboardAvoidanceViewController()) + } + ) + Item( DemoItem(text: "Keyboard Inset (Appears Later)"), selectionStyle: .selectable(), @@ -426,4 +434,3 @@ public final class DemosRootViewController : ListViewController } } } - diff --git a/ListableUI/Sources/Behavior.swift b/ListableUI/Sources/Behavior.swift index 02415906..28b64843 100644 --- a/ListableUI/Sources/Behavior.swift +++ b/ListableUI/Sources/Behavior.swift @@ -21,6 +21,17 @@ public struct Behavior : Equatable /// How to adjust the `contentInset` of the list when the keyboard visibility changes. public var keyboardAdjustmentMode : KeyboardAdjustmentMode + + /// Insets for persistent UI that visually occludes the list viewport. + /// + /// Listable applies these insets to the underlying scroll view content inset, applies the + /// relevant axis-specific edges to scroll indicators, and combines them with keyboard avoidance + /// so first-responder scrolling treats the occluded area as unavailable. These insets do not + /// change list layout geometry; use layout bounds padding for content layout spacing. + /// + /// If ``keyboardAdjustmentMode`` is ``KeyboardAdjustmentMode/custom``, the custom scroll view + /// inset callback owns the full inset calculation instead. + public var occlusionInsets : 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 +75,7 @@ public struct Behavior : Equatable isScrollEnabled: Bool = true, keyboardDismissMode : UIScrollView.KeyboardDismissMode = .interactive, keyboardAdjustmentMode : KeyboardAdjustmentMode = .adjustsWhenVisible, + occlusionInsets : UIEdgeInsets = .zero, scrollsToTop : ScrollsToTop = .enabled, selectionMode : SelectionMode = .single, underflow : Underflow = Underflow(), @@ -77,6 +89,7 @@ public struct Behavior : Equatable self.isScrollEnabled = isScrollEnabled self.keyboardDismissMode = keyboardDismissMode self.keyboardAdjustmentMode = keyboardAdjustmentMode + self.occlusionInsets = occlusionInsets self.scrollsToTop = scrollsToTop diff --git a/ListableUI/Sources/ListView/ListView.swift b/ListableUI/Sources/ListView/ListView.swift index 651b6b30..0354f571 100644 --- a/ListableUI/Sources/ListView/ListView.swift +++ b/ListableUI/Sources/ListView/ListView.swift @@ -127,7 +127,7 @@ public final class ListView : UIView name: UITextField.textDidBeginEditingNotification, object: nil ) - + NotificationCenter.default.addObserver( self, selector: #selector(textDidEndEditingNotification(_:)), @@ -416,10 +416,19 @@ public final class ListView : UIView /// whenever insets require an update. public func updateScrollViewInsets() { + // Keep both inset values so we can detect changes that affect layout. + // `adjustedContentInset` includes UIKit-managed safe area adjustments, so it can + // change even when the explicit `contentInset` value does not. + let previousContentInset = self.collectionView.contentInset + let previousAdjustedContentInset = self.collectionView.adjustedContentInset + let insets: ScrollViewInsets if case .custom = self.behavior.keyboardAdjustmentMode { + // In custom mode the consumer owns the full inset calculation. insets = self.customScrollViewInsets() } else { + // Otherwise Listable derives scroll view insets from the current keyboard + // frame and any configured persistent occlusion insets. insets = self.calculateScrollViewInsets( with: self.keyboardObserver.currentFrame(in: self) ) @@ -436,6 +445,23 @@ 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 { + // Inset changes alter the visible layout viewport. Relayout synchronously so + // visible attributes match the keyboard-adjusted bounds before UIKit's + // first-responder auto-scroll runs. Avoid inheriting the keyboard animation + // here, since animating the layout invalidation can temporarily present old + // and new cell positions at the same time. + UIView.performWithoutAnimation { + self.collectionViewLayout.setNeedsRelayout() + self.collectionView.layoutIfNeeded() + } + } } func calculateScrollViewInsets(with keyboardFrame : KeyboardFrame?) -> ScrollViewInsets { @@ -467,12 +493,20 @@ public final class ListView : UIView } }() + let occlusionInsets = self.behavior.occlusionInsets + let scrollInsets = modified(self.scrollIndicatorInsets) { - $0.bottom = max($0.bottom, keyboardBottomInset) + $0.top = max($0.top, occlusionInsets.top) + $0.left = max($0.left, occlusionInsets.left) + $0.bottom = max($0.bottom, keyboardBottomInset + occlusionInsets.bottom) + $0.right = max($0.right, occlusionInsets.right) } let contentInsets = modified(self.collectionView.contentInset) { - $0.bottom = keyboardBottomInset + $0.top = occlusionInsets.top + $0.left = occlusionInsets.left + $0.bottom = keyboardBottomInset + occlusionInsets.bottom + $0.right = occlusionInsets.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() } diff --git a/ListableUI/Tests/BehaviorTests.swift b/ListableUI/Tests/BehaviorTests.swift index 2189562c..560e0bb6 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.occlusionInsets, .zero) XCTAssertEqual(behavior.selectionMode, .single) diff --git a/ListableUI/Tests/ListView/ListViewTests.swift b/ListableUI/Tests/ListView/ListViewTests.swift index 4846e55d..e93c220a 100644 --- a/ListableUI/Tests/ListView/ListViewTests.swift +++ b/ListableUI/Tests/ListView/ListViewTests.swift @@ -139,7 +139,7 @@ class ListViewTests: XCTestCase self.testcase("Overlapping Keyboard Frame") { let insets = listView.calculateScrollViewInsets( - with:.overlapping(frame: CGRect(x: 0, y: 200, width: 200, height: 200)) + with: .overlapping(frame: CGRect(x: 0, y: 200, width: 200, height: 200)) ) XCTAssertEqual( @@ -157,6 +157,84 @@ class ListViewTests: XCTestCase UIEdgeInsets(top: 10, left: 0, bottom: 200, right: 0) ) } + + self.testcase("Overlapping Keyboard Frame With Occlusion Insets") { + listView.behavior.keyboardAdjustmentMode = .adjustsWhenVisible + listView.behavior.occlusionInsets = 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 Occlusion Insets") { + listView.behavior.keyboardAdjustmentMode = .adjustsWhenVisible + listView.behavior.occlusionInsets = UIEdgeInsets(top: 1, left: 2, bottom: 50, right: 4) + + let insets = listView.calculateScrollViewInsets(with: .nonOverlapping) + + XCTAssertEqual( + insets.content, + UIEdgeInsets(top: 1, left: 2, bottom: 50, right: 4) + ) + + XCTAssertEqual( + insets.verticalScroll, + UIEdgeInsets(top: 10, left: 0, bottom: 50, right: 0) + ) + } + + self.testcase("Keyboard Adjustment None With Occlusion Insets") { + listView.behavior.keyboardAdjustmentMode = .none + listView.behavior.occlusionInsets = 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: 50, right: 4) + ) + + XCTAssertEqual( + insets.verticalScroll, + UIEdgeInsets(top: 10, left: 0, bottom: 50, right: 0) + ) + } + + self.testcase("Occlusion Insets Update Scroll Indicator Edges") { + listView.behavior.keyboardAdjustmentMode = .adjustsWhenVisible + listView.behavior.occlusionInsets = UIEdgeInsets(top: 11, left: 22, bottom: 33, right: 44) + listView.scrollIndicatorInsets = .zero + + let insets = listView.calculateScrollViewInsets(with: .nonOverlapping) + + XCTAssertEqual( + insets.horizontalScroll, + UIEdgeInsets(top: 0, left: 22, bottom: 0, right: 44) + ) + + XCTAssertEqual( + insets.verticalScroll, + UIEdgeInsets(top: 11, left: 0, bottom: 33, right: 0) + ) + } } func test_change_size() { diff --git a/README.md b/README.md index b8d41b8f..ef086692 100644 --- a/README.md +++ b/README.md @@ -146,10 +146,20 @@ Finally, the `Behavior` and `Behavior.Underflow` allows customizing what happen public struct Behavior : Equatable { public var keyboardDismissMode : UIScrollView.KeyboardDismissMode + + public var keyboardAdjustmentMode : KeyboardAdjustmentMode + + public var occlusionInsets : UIEdgeInsets public var underflow : Underflow ``` +`occlusionInsets` lets callers reserve scroll viewport space for persistent overlays, +such as a floating bottom bar. Listable applies these insets to the scroll view content +inset, applies the relevant axis-specific edges to scroll indicators, and combines them +with keyboard avoidance so first-responder scrolling treats the occluded area as +unavailable. + ```swift struct Underflow : Equatable