├── .gitignore
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── KeyboardAccessory
│ ├── KeyboardAccessory.swift
│ ├── SizeReader.swift
│ ├── UIBottomBar.Constraints.swift
│ ├── UIBottomBar.Views.swift
│ └── UIBottomBar.swift
└── Tests
└── KeyboardAccessoryTests
└── KeyboardAccessoryTests.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Xavier Normant
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "KeyboardAccessory",
8 | platforms: [.iOS(.v17)],
9 | products: [
10 | // Products define the executables and libraries a package produces, making them visible to other packages.
11 | .library(
12 | name: "KeyboardAccessory",
13 | targets: ["KeyboardAccessory"]
14 | ),
15 | ],
16 | targets: [
17 | // Targets are the basic building blocks of a package, defining a module or a test suite.
18 | // Targets can depend on other targets in this package and products from dependencies.
19 | .target(
20 | name: "KeyboardAccessory"),
21 | .testTarget(
22 | name: "KeyboardAccessoryTests",
23 | dependencies: ["KeyboardAccessory"]
24 | ),
25 | ]
26 | )
27 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # KeyboardAccessory
2 |
3 | ## Problem
4 |
5 | In SwiftUI, there is this bug where a bottom view will not follow the interactive dismissal of the keyboard.
6 |
7 |
8 |
9 | ## Solution
10 |
11 | Using this package, we can solve this problem and achieve the expected behavior.
12 |
13 |
14 |
15 | ## Using the package
16 |
17 | First, you need to add this package to your project dependencies. Then, you can import the package and use the `.keyboardAccessory` modifier on any type of scrolling view.
18 |
19 | ```swift
20 | import SwiftUI
21 | import KeyboardAccessory // Import the package
22 |
23 | struct ContentView: View {
24 | var body: some View {
25 | VStack {
26 | ChatView()
27 | .keyboardAccessory {
28 | InputView() // The view you want to use as an accessory
29 | }
30 | .scrollDismissesKeyboard(.interactively) // Dismiss the keyboard interactively
31 | }
32 | }
33 | }
34 | ```
35 |
36 | There is also a `background` closure that lets you add a background view.
37 |
38 | ```swift
39 | ScrollView {}
40 | .keyboardAccessory {
41 | // Accessory
42 | } background {
43 | Rectangle()
44 | .fill(.regularMaterial)
45 | }
46 | ```
47 |
48 | ## Credits
49 |
50 | This package was built using [BottomInputBarSwiftUI](https://github.com/frogcjn/BottomInputBarSwiftUI), which is used under the MIT license as per the user's permission. I only made some changes to the modifier and created a package. All credit for the actual implementation goes to [frogcjn](https://github.com/frogcjn).
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/Sources/KeyboardAccessory/KeyboardAccessory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // KeyboardAccessory.swift
3 | // KeyboardAccessory
4 | //
5 | // Created by Xavier Normant on 31/10/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | public extension View {
11 | func keyboardAccessory(
12 | @ViewBuilder content: @escaping () -> Content,
13 | @ViewBuilder background: @escaping () -> Background = { Color.clear }
14 | ) -> some View {
15 | self.modifier(KeyboardAccessoryModifier(accessory: content, background: background))
16 | }
17 | }
18 |
19 | struct KeyboardAccessoryModifier: ViewModifier {
20 | @ViewBuilder let accessory: Accessory
21 | @ViewBuilder let background: Background
22 |
23 | @State
24 | private var height: CGFloat?
25 |
26 | func body(content: Content) -> some View {
27 | ZStack(alignment: .bottom) {
28 | content
29 | .safeAreaPadding(.bottom, self.height)
30 | .ignoresSafeArea(.all, edges: .bottom)
31 |
32 | UIBottomBar.ViewRepresentable {
33 | self.accessory
34 | } background: {
35 | self.background.readSize { self.height = $0.height }
36 | }
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Sources/KeyboardAccessory/SizeReader.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SizeReader.swift
3 | // BottomInputBarSwiftUI
4 | //
5 | // Created by Cao, Jiannan on 1/11/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct SizePreferenceKey: PreferenceKey {
11 | static let defaultValue: CGSize = .zero
12 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
13 | let next = nextValue()
14 | value.width += next.width
15 | value.height += next.height
16 | }
17 | }
18 |
19 | @MainActor
20 | extension View {
21 | func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
22 | background(
23 | GeometryReader { geometryProxy in
24 | Color.clear.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
25 | }
26 | )
27 | .onPreferenceChange(SizePreferenceKey.self, perform: onChange)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Sources/KeyboardAccessory/UIBottomBar.Constraints.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIBottomBar.Constraints.swift
3 | // BottomInputBarSwiftUI
4 | //
5 | // Created by Cao, Jiannan on 1/10/24.
6 | //
7 |
8 | import UIKit
9 |
10 | protocol Constraints {
11 | associatedtype Source
12 | associatedtype Target
13 | init?(source: Source, target: Target?)
14 | func install()
15 | func uninstall()
16 |
17 | var constraints: [NSLayoutConstraint] { get }
18 | }
19 |
20 | extension Constraints {
21 | @MainActor
22 | func install() {
23 | NSLayoutConstraint.activate(constraints)
24 | }
25 |
26 | @MainActor
27 | func uninstall() {
28 | NSLayoutConstraint.deactivate(constraints)
29 | }
30 | }
31 |
32 | extension UIBottomBar {
33 | struct SuperViewConstraints : @preconcurrency Constraints {
34 |
35 | private let source: UIBottomBar.Views
36 | private let target: UIView
37 | let constraints: [NSLayoutConstraint]
38 |
39 | @MainActor
40 | init?(source: UIBottomBar.Views, target: UIView? = nil) {
41 | guard let target else { return nil }
42 |
43 | let barView = source.barView
44 | let backgroundView = source.backgroundView
45 |
46 | let constraints = [
47 | backgroundView.topAnchor.constraint(equalTo: target.topAnchor),
48 | backgroundView.bottomAnchor.constraint(equalTo: target.bottomAnchor),
49 | backgroundView.leadingAnchor.constraint(equalTo: target.leadingAnchor),
50 | backgroundView.trailingAnchor.constraint(equalTo:target.trailingAnchor),
51 |
52 | barView.topAnchor.constraint(equalTo: backgroundView.topAnchor),
53 | barView.bottomAnchor.constraint(lessThanOrEqualTo: backgroundView.bottomAnchor),
54 | barView.leadingAnchor.constraint(greaterThanOrEqualTo: backgroundView.leadingAnchor),
55 | barView.trailingAnchor.constraint(lessThanOrEqualTo: backgroundView.trailingAnchor)
56 | ]
57 |
58 | self.source = source
59 | self.target = target
60 | self.constraints = constraints
61 | }
62 | }
63 |
64 | @MainActor
65 | struct WindowConstraints : @preconcurrency Constraints {
66 |
67 | let source: UIBottomBar.Views
68 | let target: Target
69 |
70 |
71 | let constraints: [NSLayoutConstraint]
72 |
73 |
74 | init?(source: UIBottomBar.Views, target: Target?) {
75 |
76 | let barView = source.barView
77 | let backgroundView = source.backgroundView
78 |
79 | guard let target else { return nil }
80 | let window = target.window
81 | let keyboardLayoutGuide = target.keyboardLayoutGuide
82 | let safeAreaLayoutGuide = target.safeAreaLayoutGuide
83 |
84 | barView.setContentCompressionResistancePriority(.required, for: .horizontal)
85 | let constraints = [
86 | // backgroundView == window (without top)
87 | backgroundView.leadingAnchor.constraint(equalTo: window.leadingAnchor),
88 | backgroundView.trailingAnchor.constraint(equalTo: window.trailingAnchor),
89 | backgroundView.bottomAnchor.constraint(equalTo: window.bottomAnchor),
90 |
91 | // hostingView | keyboard (V)
92 | barView.bottomAnchor.constraint(equalTo: keyboardLayoutGuide.topAnchor),
93 |
94 | // hostingView == safeArea (H)
95 | barView.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor),
96 | {
97 | let constraint = barView.widthAnchor.constraint(equalTo: safeAreaLayoutGuide.widthAnchor)
98 | constraint.priority = .defaultHigh - 1
99 | return constraint
100 | }(),
101 |
102 | // hostingView <= safeArea
103 | barView.topAnchor.constraint(greaterThanOrEqualTo: safeAreaLayoutGuide.topAnchor),
104 | barView.bottomAnchor.constraint(lessThanOrEqualTo: safeAreaLayoutGuide.bottomAnchor),
105 | barView.leadingAnchor.constraint(greaterThanOrEqualTo: safeAreaLayoutGuide.leadingAnchor),
106 | barView.trailingAnchor.constraint(lessThanOrEqualTo: safeAreaLayoutGuide.trailingAnchor)
107 | ]
108 |
109 | self.source = source
110 | self.target = target
111 | self.constraints = constraints
112 | }
113 |
114 | func updateKeyboardDismissPadding() {
115 | target.keyboardLayoutGuide.keyboardDismissPadding = source.barView.intrinsicContentSize.height
116 | }
117 |
118 | struct Target {
119 | let window: UIWindow
120 | let keyboardLayoutGuide: UIKeyboardLayoutGuide
121 | let safeAreaLayoutGuide: UILayoutGuide
122 |
123 | @MainActor
124 | init?(window: UIWindow?) {
125 | guard let window, let rootView = window.rootViewController?.view else { return nil }
126 | self.window = window
127 | keyboardLayoutGuide = rootView.keyboardLayoutGuide
128 | safeAreaLayoutGuide = window.safeAreaLayoutGuide
129 | }
130 | }
131 | }
132 | }
133 |
134 |
135 |
--------------------------------------------------------------------------------
/Sources/KeyboardAccessory/UIBottomBar.Views.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIBottomBar.Views.swift
3 | // BottomInputBarSwiftUI
4 | //
5 | // Created by Cao, Jiannan on 1/10/24.
6 | //
7 |
8 | import UIKit
9 |
10 | @MainActor
11 | extension UIBottomBar {
12 | struct Views {
13 | let barView: UIView
14 | let backgroundView: UIView
15 | let floatingView: UIView
16 |
17 | @MainActor
18 | init(barView: UIView, backgroundView: UIView, superview: UIView) {
19 | barView.translatesAutoresizingMaskIntoConstraints = false
20 | self.barView = barView
21 |
22 | backgroundView.translatesAutoresizingMaskIntoConstraints = false
23 | self.backgroundView = backgroundView
24 |
25 | let floatingView = UIView()
26 | floatingView.translatesAutoresizingMaskIntoConstraints = false
27 | self.floatingView = floatingView
28 |
29 | superview.addSubview(floatingView)
30 | floatingView.addSubview(backgroundView)
31 | floatingView.addSubview(barView)
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Sources/KeyboardAccessory/UIBottomBar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIButtonBar.swift
3 | // BottomInputBarSwiftUI
4 | //
5 | // Created by Cao, Jiannan on 1/10/24.
6 | //
7 |
8 | import UIKit
9 | import SwiftUI
10 |
11 |
12 | class UIBottomBar : UIView {
13 |
14 | required init?(coder: NSCoder) {
15 | fatalError("init(coder:) has not been implemented")
16 | }
17 |
18 | var views: Views!
19 |
20 | init(barView: UIView, backgroundView: UIView, frame: CGRect = .zero) {
21 | super.init(frame: frame)
22 | self.views = Views(barView: barView, backgroundView: backgroundView, superview: self)
23 | }
24 |
25 | var keyboardConstraints: WindowConstraints? {
26 | didSet {
27 | oldValue?.uninstall()
28 | keyboardConstraints?.install()
29 | }
30 | }
31 |
32 | var superViewConstraints: SuperViewConstraints? {
33 | didSet {
34 | oldValue?.uninstall()
35 | superViewConstraints?.install()
36 | }
37 | }
38 |
39 | override func didMoveToSuperview() {
40 | super.didMoveToSuperview()
41 | superViewConstraints = SuperViewConstraints(source: views, target: views.floatingView)
42 | }
43 |
44 | override func didMoveToWindow() {
45 | super.didMoveToWindow()
46 | keyboardConstraints = WindowConstraints(source: views, target: WindowConstraints.Target(window: window))
47 | }
48 |
49 | override func layoutSubviews() {
50 | super.layoutSubviews()
51 | keyboardConstraints?.updateKeyboardDismissPadding()
52 | }
53 |
54 | override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
55 | views.floatingView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority)
56 | }
57 | }
58 |
59 | extension UIBottomBar {
60 |
61 | struct ViewRepresentable : UIViewRepresentable {
62 | @ViewBuilder
63 | let bottomBar: BottomBar
64 |
65 | @ViewBuilder
66 | let background :Background
67 |
68 | typealias UIViewType = UIBottomBar
69 |
70 | func makeUIView(context: Context) -> UIViewType {
71 | UIBottomBar(
72 | barView: _UIHostingView(rootView: bottomBar),
73 | backgroundView: _UIHostingView(rootView: background)
74 | )
75 | }
76 |
77 | func updateUIView(_ uiView: UIViewType, context: Context) {
78 |
79 | }
80 |
81 | func sizeThatFits(_ proposal: ProposedViewSize, uiView: Self.UIViewType, context: Self.Context) -> CGSize? {
82 | uiView.systemLayoutSizeFitting(
83 | {
84 | var size = proposal.replacingUnspecifiedDimensions()
85 | size.height = UIView.layoutFittingCompressedSize.height
86 | return size
87 | }(),
88 | withHorizontalFittingPriority: .defaultHigh,
89 | verticalFittingPriority: .fittingSizeLevel
90 | )
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/Tests/KeyboardAccessoryTests/KeyboardAccessoryTests.swift:
--------------------------------------------------------------------------------
1 | import Testing
2 | @testable import KeyboardAccessory
3 |
4 | @Test func example() async throws {
5 | // Write your test here and use APIs like `#expect(...)` to check expected conditions.
6 | }
7 |
--------------------------------------------------------------------------------