Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
9 changes: 8 additions & 1 deletion Development/Sources/Demos/DemosRootViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -426,4 +434,3 @@ public final class DemosRootViewController : ListViewController
}
}
}

13 changes: 13 additions & 0 deletions ListableUI/Sources/Behavior.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
Expand All @@ -77,6 +89,7 @@ public struct Behavior : Equatable
self.isScrollEnabled = isScrollEnabled
self.keyboardDismissMode = keyboardDismissMode
self.keyboardAdjustmentMode = keyboardAdjustmentMode
self.occlusionInsets = occlusionInsets

self.scrollsToTop = scrollsToTop

Expand Down
45 changes: 39 additions & 6 deletions ListableUI/Sources/ListView/ListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ public final class ListView : UIView
name: UITextField.textDidBeginEditingNotification,
object: nil
)

NotificationCenter.default.addObserver(
self,
selector: #selector(textDidEndEditingNotification(_:)),
Expand Down Expand Up @@ -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)
)
Expand All @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -659,7 +693,6 @@ public final class ListView : UIView
completion: ScrollCompletion? = nil
) -> Bool
{

let storageContent = storage.allContent

// Make sure the section identifier is valid.
Expand Down Expand Up @@ -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()
}
Expand Down
1 change: 1 addition & 0 deletions ListableUI/Tests/BehaviorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class BehaviorTests: XCTestCase

XCTAssertEqual(behavior.keyboardDismissMode, .interactive)
XCTAssertEqual(behavior.keyboardAdjustmentMode, .adjustsWhenVisible)
XCTAssertEqual(behavior.occlusionInsets, .zero)

XCTAssertEqual(behavior.selectionMode, .single)

Expand Down
Loading
Loading