├── .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 | Screenshot of the problem 8 | 9 | ## Solution 10 | 11 | Using this package, we can solve this problem and achieve the expected behavior. 12 | 13 | Screenshot of the problem 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 | --------------------------------------------------------------------------------