├── .github ├── release-drafter.yml └── workflows │ ├── ci.yml │ ├── publish-release-note.yml │ └── release.yml ├── .gitignore ├── .spi.yml ├── FloatingBottomSheet.podspec ├── LICENSE ├── Package.swift ├── README.md ├── Sample ├── Sample.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── Sample.xcscheme ├── Sample │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── Info.plist │ ├── Root │ │ ├── SampleModels.swift │ │ └── SampleViewController.swift │ ├── SceneDelegate.swift │ └── ViewControllers │ │ └── PlainViewController.swift ├── SampleTests │ └── SampleTests.swift └── SampleUITests │ ├── SampleUITests.swift │ └── SampleUITestsLaunchTests.swift ├── Sources └── FloatingBottomSheet │ ├── Animator │ ├── FloatingBottomSheetAnimator.swift │ └── FloatingBottomSheetPresentationAnimator.swift │ ├── Presentable │ ├── FloatingBottomSheet.swift │ ├── FloatingBottomSheetPresentable+Default.swift │ ├── FloatingBottomSheetPresentable+Internal.swift │ ├── FloatingBottomSheetPresentable+UIViewController.swift │ └── FloatingBottomSheetPresentable.swift │ ├── Presentation │ ├── FloatingBottomSheetContainerView.swift │ ├── FloatingBottomSheetHandleMetric.swift │ ├── FloatingBottomSheetPresentationController.swift │ └── FloatingBottomSheetPresentationDelegate.swift │ ├── Presenter │ ├── FloatingBottomSheetPresenter.swift │ └── UIViewController+FloatingBottomSheetPresenter.swift │ └── Utils │ └── UIColor+Init.swift ├── Tests └── FloatingBottomSheetTests │ └── FloatingBottomSheetTests.swift └── assets ├── floatingbottomsheet.gif └── logo.png /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "🚀 $RESOLVED_VERSION" 2 | tag-template: "$RESOLVED_VERSION" 3 | categories: 4 | - title: "✨ Features" 5 | label: "feature" 6 | 7 | - title: "😎 Update" 8 | label: "update" 9 | 10 | - title: "🐛 Bug Fixes" 11 | label: "bug" 12 | 13 | - title: "🤓 Improvements" 14 | label: "improvement" 15 | 16 | - title: "📚 Documentation" 17 | label: "docs" 18 | 19 | - title: "🧰 Maintenance" 20 | labels: 21 | - "chore" 22 | 23 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 24 | change-title-escapes: '\<*_&' 25 | template: | 26 | ## What’s Changed 27 | 28 | $CHANGES 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | concurrency: 10 | group: ${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build_and_test: 15 | runs-on: macos-14 16 | 17 | env: 18 | SCHEME: FloatingBottomSheet 19 | SDK: iphonesimulator 20 | DESTINATION: platform=iOS Simulator,name=iPhone 15,OS=17.2 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Setup Xcode Version 26 | run: sudo xcode-select --switch /Applications/Xcode_15.2.app 27 | shell: bash 28 | 29 | - name: Run `build` 30 | run: | 31 | set -o pipefail && xcodebuild build \ 32 | -scheme "$SCHEME" \ 33 | -sdk "$SDK" \ 34 | -destination "$DESTINATION" \ 35 | -configuration Debug \ 36 | CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO \ 37 | | xcbeautify --renderer github-actions 38 | -------------------------------------------------------------------------------- /.github/workflows/publish-release-note.yml: -------------------------------------------------------------------------------- 1 | name: Publish release note 2 | on: 3 | push: 4 | tags: 5 | - "[0-9]+.[0-9]+.[0-9]+" 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: read 10 | 11 | jobs: 12 | publish-release-note: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - uses: release-drafter/release-drafter@v6 19 | with: 20 | version: ${{ github.ref_name }} 21 | 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [published] 5 | 6 | jobs: 7 | release: 8 | runs-on: macos-14 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: extract_version_info 14 | run: echo "version=$(git describe --tags $(git rev-list --tags --max-count=1))" >> $GITHUB_OUTPUT 15 | id: extract_version_name 16 | 17 | - name: Deploy to Cocoapods 18 | run: | 19 | set -eo pipefail 20 | pod lib lint --allow-warnings 21 | pod trunk push --allow-warnings 22 | env: 23 | LIB_VERSION: ${{ steps.extract_version_name.outputs.version }} 24 | COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} 25 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - platform: ios 5 | documentation_targets: 6 | - FloatingBottomSheet 7 | -------------------------------------------------------------------------------- /FloatingBottomSheet.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = 'FloatingBottomSheet' 3 | spec.version = ENV['LIB_VERSION'] || '0.1.0' 4 | spec.summary = 'An easy way to present a bottom sheet with a floating effect.' 5 | spec.homepage = 'https://github.com/OhKanghoon/FloatingBottomSheet' 6 | spec.license = { type: 'MIT', file: 'LICENSE' } 7 | spec.author = { 'OhKanghoon' => 'ggaa96@naver.com' } 8 | spec.source = { git: 'https://github.com/OhKanghoon/FloatingBottomSheet.git', tag: spec.version.to_s } 9 | 10 | spec.source_files = 'Sources/**/*.{swift,h,m}' 11 | spec.frameworks = 'UIKit' 12 | spec.swift_version = '5.0' 13 | 14 | spec.ios.deployment_target = '11.0' 15 | end 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ray (Kanghoon Oh) 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:5.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "FloatingBottomSheet", 7 | platforms: [.iOS(.v11)], 8 | products: [ 9 | .library( 10 | name: "FloatingBottomSheet", 11 | targets: ["FloatingBottomSheet"] 12 | ), 13 | ], 14 | targets: [ 15 | .target( 16 | name: "FloatingBottomSheet" 17 | ), 18 | .testTarget( 19 | name: "FloatingBottomSheetTests", 20 | dependencies: ["FloatingBottomSheet"] 21 | ), 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FloatingBottomSheet 2 | 3 | Logo 4 | 5 | [![Build Status](https://github.com/OhKanghoon/FloatingBottomSheet/workflows/CI/badge.svg?branch=main)](https://github.com/OhKanghoon/FloatingBottomSheet/actions?query=branch%3Amain+workflow%3ACI) 6 | [![Swift Package Manager compatible](https://img.shields.io/badge/SPM-compatible-4BC51D.svg?style=flat)](https://github.com/apple/swift-package-manager) 7 | [![Swift Versions](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FOhKanghoon%2FFloatingBottomSheet%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/OhKanghoon/FloatingBottomSheet) 8 | [![Platform](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FOhKanghoon%2FFloatingBottomSheet%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/OhKanghoon/FloatingBottomSheet) 9 | 10 | A library that displays floating bottom sheet. 11 | 12 | See the [FloatingBottomSheet DocC documentation](https://swiftpackageindex.com/OhKanghoon/FloatingBottomSheet/main/documentation/floatingbottomsheet) hosted on the [Swift Package Index](https://swiftpackageindex.com/). 13 | 14 | ## Compatibility 15 | 16 | FloatingBottomSheet requires **iOS 11+** and is compatible with **Swift 5** projects. 17 | 18 | ## Installation 19 | 20 | ### [Swift Package Manager](https://swift.org/package-manager). 21 | 22 | The preferred way of installing FloatingBottomSheet is via the Swift Package Manager 23 | 24 | 1. In Xcode, open your project and navigate to **File** → **Add Packages** 25 | 2. Paste the repository URL (`https://github.com/OhKanghoon/FloatingBottomSheet`) and click **Next**. 26 | 3. For **Rules**, select **Up to Next Major Version**. 27 | 4. Click **Add Package**. 28 | 29 | 30 | ### [CocoaPods](https://guides.cocoapods.org/using/using-cocoapods.html) 31 | 32 | ```ruby 33 | # Podfile 34 | use_frameworks! 35 | 36 | target 'YOUR_TARGET_NAME' do 37 | pod 'FloatingBottomSheet' 38 | end 39 | ``` 40 | 41 | Replace `YOUR_TARGET_NAME` and then, in the `Podfile` directory, type: 42 | 43 | ```bash 44 | $ pod install 45 | ``` 46 | 47 | ## Usage 48 | 49 | Preview 50 | 51 | ### Configuration 52 | 53 | To use the FloatingBottomSheet, your ViewController must conform to the `FloatingBottomSheetPresentable` protocol. 54 | 55 | Start by implementing the `bottomSheetScrollable` and `bottomSheetHeight` properties. 56 | 57 | ```swift 58 | final class ViewController: UIViewController, FloatingBottomSheetPresentable { 59 | 60 | var bottomSheetScrollable: UIScrollView? { 61 | // Return a scrollable view 62 | } 63 | 64 | var bottomSheetHeight: CGFloat { 65 | // Set the height of the bottom sheet 66 | } 67 | } 68 | ``` 69 | 70 | ### Present bottom sheet 71 | 72 | Simply present the floating bottom sheet using the presentFloatingBottomSheet function like this: 73 | 74 | ```swift 75 | let viewController = ViewController() 76 | 77 | presentFloatingBottomSheet(viewController) 78 | ``` 79 | 80 | ### Updates bottom sheet height at runtime 81 | 82 | To update the bottom sheet's height dynamically during runtime, use the following code: 83 | 84 | ```swift 85 | bottomSheetHeight = 400.0 86 | bottomSheetPerformLayout(animated: true) 87 | ``` 88 | 89 | You can change the value of bottomSheetHeight to your desired height 90 | and then call `bottomSheetPerformLayout` function to update the bottom sheet's height with optional animation. 91 | 92 | If you don't want animation, set `animated` to false. 93 | 94 | ## License 95 | 96 | FloatingBottomSheet is under MIT license. See the [LICENSE](https://github.com/OhKanghoon/FloatingBottomSheet/blob/main/LICENSE) for more info. 97 | -------------------------------------------------------------------------------- /Sample/Sample.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 60; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 711A09302AC971CD0013EB44 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711A092F2AC971CD0013EB44 /* AppDelegate.swift */; }; 11 | 711A09322AC971CD0013EB44 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711A09312AC971CD0013EB44 /* SceneDelegate.swift */; }; 12 | 711A09392AC971CE0013EB44 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 711A09382AC971CE0013EB44 /* Assets.xcassets */; }; 13 | 711A093C2AC971CE0013EB44 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 711A093A2AC971CE0013EB44 /* LaunchScreen.storyboard */; }; 14 | 711A09472AC971CE0013EB44 /* SampleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711A09462AC971CE0013EB44 /* SampleTests.swift */; }; 15 | 711A09512AC971CE0013EB44 /* SampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711A09502AC971CE0013EB44 /* SampleUITests.swift */; }; 16 | 711A09532AC971CE0013EB44 /* SampleUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711A09522AC971CE0013EB44 /* SampleUITestsLaunchTests.swift */; }; 17 | 711A09612AC971DC0013EB44 /* FloatingBottomSheet in Frameworks */ = {isa = PBXBuildFile; productRef = 711A09602AC971DC0013EB44 /* FloatingBottomSheet */; }; 18 | 711A09642AC971FF0013EB44 /* PlainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711A09632AC971FF0013EB44 /* PlainViewController.swift */; }; 19 | 711A09662AC972510013EB44 /* SampleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711A09652AC972510013EB44 /* SampleViewController.swift */; }; 20 | 711A096C2AC9BAD50013EB44 /* SampleModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711A096B2AC9BAD50013EB44 /* SampleModels.swift */; }; 21 | /* End PBXBuildFile section */ 22 | 23 | /* Begin PBXContainerItemProxy section */ 24 | 711A09432AC971CE0013EB44 /* PBXContainerItemProxy */ = { 25 | isa = PBXContainerItemProxy; 26 | containerPortal = 711A09242AC971CD0013EB44 /* Project object */; 27 | proxyType = 1; 28 | remoteGlobalIDString = 711A092B2AC971CD0013EB44; 29 | remoteInfo = Sample; 30 | }; 31 | 711A094D2AC971CE0013EB44 /* PBXContainerItemProxy */ = { 32 | isa = PBXContainerItemProxy; 33 | containerPortal = 711A09242AC971CD0013EB44 /* Project object */; 34 | proxyType = 1; 35 | remoteGlobalIDString = 711A092B2AC971CD0013EB44; 36 | remoteInfo = Sample; 37 | }; 38 | /* End PBXContainerItemProxy section */ 39 | 40 | /* Begin PBXFileReference section */ 41 | 711A092C2AC971CD0013EB44 /* Sample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 42 | 711A092F2AC971CD0013EB44 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 43 | 711A09312AC971CD0013EB44 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 44 | 711A09382AC971CE0013EB44 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 45 | 711A093B2AC971CE0013EB44 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 46 | 711A093D2AC971CE0013EB44 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 47 | 711A09422AC971CE0013EB44 /* SampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 48 | 711A09462AC971CE0013EB44 /* SampleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleTests.swift; sourceTree = ""; }; 49 | 711A094C2AC971CE0013EB44 /* SampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 50 | 711A09502AC971CE0013EB44 /* SampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleUITests.swift; sourceTree = ""; }; 51 | 711A09522AC971CE0013EB44 /* SampleUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleUITestsLaunchTests.swift; sourceTree = ""; }; 52 | 711A09632AC971FF0013EB44 /* PlainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainViewController.swift; sourceTree = ""; }; 53 | 711A09652AC972510013EB44 /* SampleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleViewController.swift; sourceTree = ""; }; 54 | 711A096B2AC9BAD50013EB44 /* SampleModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleModels.swift; sourceTree = ""; }; 55 | /* End PBXFileReference section */ 56 | 57 | /* Begin PBXFrameworksBuildPhase section */ 58 | 711A09292AC971CD0013EB44 /* Frameworks */ = { 59 | isa = PBXFrameworksBuildPhase; 60 | buildActionMask = 2147483647; 61 | files = ( 62 | 711A09612AC971DC0013EB44 /* FloatingBottomSheet in Frameworks */, 63 | ); 64 | runOnlyForDeploymentPostprocessing = 0; 65 | }; 66 | 711A093F2AC971CE0013EB44 /* Frameworks */ = { 67 | isa = PBXFrameworksBuildPhase; 68 | buildActionMask = 2147483647; 69 | files = ( 70 | ); 71 | runOnlyForDeploymentPostprocessing = 0; 72 | }; 73 | 711A09492AC971CE0013EB44 /* Frameworks */ = { 74 | isa = PBXFrameworksBuildPhase; 75 | buildActionMask = 2147483647; 76 | files = ( 77 | ); 78 | runOnlyForDeploymentPostprocessing = 0; 79 | }; 80 | /* End PBXFrameworksBuildPhase section */ 81 | 82 | /* Begin PBXGroup section */ 83 | 711A09232AC971CD0013EB44 = { 84 | isa = PBXGroup; 85 | children = ( 86 | 711A092E2AC971CD0013EB44 /* Sample */, 87 | 711A09452AC971CE0013EB44 /* SampleTests */, 88 | 711A094F2AC971CE0013EB44 /* SampleUITests */, 89 | 711A092D2AC971CD0013EB44 /* Products */, 90 | ); 91 | sourceTree = ""; 92 | }; 93 | 711A092D2AC971CD0013EB44 /* Products */ = { 94 | isa = PBXGroup; 95 | children = ( 96 | 711A092C2AC971CD0013EB44 /* Sample.app */, 97 | 711A09422AC971CE0013EB44 /* SampleTests.xctest */, 98 | 711A094C2AC971CE0013EB44 /* SampleUITests.xctest */, 99 | ); 100 | name = Products; 101 | sourceTree = ""; 102 | }; 103 | 711A092E2AC971CD0013EB44 /* Sample */ = { 104 | isa = PBXGroup; 105 | children = ( 106 | 711A096A2AC9BAC70013EB44 /* Root */, 107 | 711A09622AC971F70013EB44 /* ViewControllers */, 108 | 711A092F2AC971CD0013EB44 /* AppDelegate.swift */, 109 | 711A09312AC971CD0013EB44 /* SceneDelegate.swift */, 110 | 711A09382AC971CE0013EB44 /* Assets.xcassets */, 111 | 711A093A2AC971CE0013EB44 /* LaunchScreen.storyboard */, 112 | 711A093D2AC971CE0013EB44 /* Info.plist */, 113 | ); 114 | path = Sample; 115 | sourceTree = ""; 116 | }; 117 | 711A09452AC971CE0013EB44 /* SampleTests */ = { 118 | isa = PBXGroup; 119 | children = ( 120 | 711A09462AC971CE0013EB44 /* SampleTests.swift */, 121 | ); 122 | path = SampleTests; 123 | sourceTree = ""; 124 | }; 125 | 711A094F2AC971CE0013EB44 /* SampleUITests */ = { 126 | isa = PBXGroup; 127 | children = ( 128 | 711A09502AC971CE0013EB44 /* SampleUITests.swift */, 129 | 711A09522AC971CE0013EB44 /* SampleUITestsLaunchTests.swift */, 130 | ); 131 | path = SampleUITests; 132 | sourceTree = ""; 133 | }; 134 | 711A09622AC971F70013EB44 /* ViewControllers */ = { 135 | isa = PBXGroup; 136 | children = ( 137 | 711A09632AC971FF0013EB44 /* PlainViewController.swift */, 138 | ); 139 | path = ViewControllers; 140 | sourceTree = ""; 141 | }; 142 | 711A096A2AC9BAC70013EB44 /* Root */ = { 143 | isa = PBXGroup; 144 | children = ( 145 | 711A09652AC972510013EB44 /* SampleViewController.swift */, 146 | 711A096B2AC9BAD50013EB44 /* SampleModels.swift */, 147 | ); 148 | path = Root; 149 | sourceTree = ""; 150 | }; 151 | /* End PBXGroup section */ 152 | 153 | /* Begin PBXNativeTarget section */ 154 | 711A092B2AC971CD0013EB44 /* Sample */ = { 155 | isa = PBXNativeTarget; 156 | buildConfigurationList = 711A09562AC971CE0013EB44 /* Build configuration list for PBXNativeTarget "Sample" */; 157 | buildPhases = ( 158 | 711A09282AC971CD0013EB44 /* Sources */, 159 | 711A09292AC971CD0013EB44 /* Frameworks */, 160 | 711A092A2AC971CD0013EB44 /* Resources */, 161 | ); 162 | buildRules = ( 163 | ); 164 | dependencies = ( 165 | ); 166 | name = Sample; 167 | packageProductDependencies = ( 168 | 711A09602AC971DC0013EB44 /* FloatingBottomSheet */, 169 | ); 170 | productName = Sample; 171 | productReference = 711A092C2AC971CD0013EB44 /* Sample.app */; 172 | productType = "com.apple.product-type.application"; 173 | }; 174 | 711A09412AC971CE0013EB44 /* SampleTests */ = { 175 | isa = PBXNativeTarget; 176 | buildConfigurationList = 711A09592AC971CE0013EB44 /* Build configuration list for PBXNativeTarget "SampleTests" */; 177 | buildPhases = ( 178 | 711A093E2AC971CE0013EB44 /* Sources */, 179 | 711A093F2AC971CE0013EB44 /* Frameworks */, 180 | 711A09402AC971CE0013EB44 /* Resources */, 181 | ); 182 | buildRules = ( 183 | ); 184 | dependencies = ( 185 | 711A09442AC971CE0013EB44 /* PBXTargetDependency */, 186 | ); 187 | name = SampleTests; 188 | productName = SampleTests; 189 | productReference = 711A09422AC971CE0013EB44 /* SampleTests.xctest */; 190 | productType = "com.apple.product-type.bundle.unit-test"; 191 | }; 192 | 711A094B2AC971CE0013EB44 /* SampleUITests */ = { 193 | isa = PBXNativeTarget; 194 | buildConfigurationList = 711A095C2AC971CE0013EB44 /* Build configuration list for PBXNativeTarget "SampleUITests" */; 195 | buildPhases = ( 196 | 711A09482AC971CE0013EB44 /* Sources */, 197 | 711A09492AC971CE0013EB44 /* Frameworks */, 198 | 711A094A2AC971CE0013EB44 /* Resources */, 199 | ); 200 | buildRules = ( 201 | ); 202 | dependencies = ( 203 | 711A094E2AC971CE0013EB44 /* PBXTargetDependency */, 204 | ); 205 | name = SampleUITests; 206 | productName = SampleUITests; 207 | productReference = 711A094C2AC971CE0013EB44 /* SampleUITests.xctest */; 208 | productType = "com.apple.product-type.bundle.ui-testing"; 209 | }; 210 | /* End PBXNativeTarget section */ 211 | 212 | /* Begin PBXProject section */ 213 | 711A09242AC971CD0013EB44 /* Project object */ = { 214 | isa = PBXProject; 215 | attributes = { 216 | BuildIndependentTargetsInParallel = 1; 217 | LastSwiftUpdateCheck = 1500; 218 | LastUpgradeCheck = 1500; 219 | TargetAttributes = { 220 | 711A092B2AC971CD0013EB44 = { 221 | CreatedOnToolsVersion = 15.0; 222 | }; 223 | 711A09412AC971CE0013EB44 = { 224 | CreatedOnToolsVersion = 15.0; 225 | TestTargetID = 711A092B2AC971CD0013EB44; 226 | }; 227 | 711A094B2AC971CE0013EB44 = { 228 | CreatedOnToolsVersion = 15.0; 229 | TestTargetID = 711A092B2AC971CD0013EB44; 230 | }; 231 | }; 232 | }; 233 | buildConfigurationList = 711A09272AC971CD0013EB44 /* Build configuration list for PBXProject "Sample" */; 234 | compatibilityVersion = "Xcode 14.0"; 235 | developmentRegion = en; 236 | hasScannedForEncodings = 0; 237 | knownRegions = ( 238 | en, 239 | Base, 240 | ); 241 | mainGroup = 711A09232AC971CD0013EB44; 242 | packageReferences = ( 243 | 711A095F2AC971DC0013EB44 /* XCLocalSwiftPackageReference ".." */, 244 | ); 245 | productRefGroup = 711A092D2AC971CD0013EB44 /* Products */; 246 | projectDirPath = ""; 247 | projectRoot = ""; 248 | targets = ( 249 | 711A092B2AC971CD0013EB44 /* Sample */, 250 | 711A09412AC971CE0013EB44 /* SampleTests */, 251 | 711A094B2AC971CE0013EB44 /* SampleUITests */, 252 | ); 253 | }; 254 | /* End PBXProject section */ 255 | 256 | /* Begin PBXResourcesBuildPhase section */ 257 | 711A092A2AC971CD0013EB44 /* Resources */ = { 258 | isa = PBXResourcesBuildPhase; 259 | buildActionMask = 2147483647; 260 | files = ( 261 | 711A093C2AC971CE0013EB44 /* LaunchScreen.storyboard in Resources */, 262 | 711A09392AC971CE0013EB44 /* Assets.xcassets in Resources */, 263 | ); 264 | runOnlyForDeploymentPostprocessing = 0; 265 | }; 266 | 711A09402AC971CE0013EB44 /* Resources */ = { 267 | isa = PBXResourcesBuildPhase; 268 | buildActionMask = 2147483647; 269 | files = ( 270 | ); 271 | runOnlyForDeploymentPostprocessing = 0; 272 | }; 273 | 711A094A2AC971CE0013EB44 /* Resources */ = { 274 | isa = PBXResourcesBuildPhase; 275 | buildActionMask = 2147483647; 276 | files = ( 277 | ); 278 | runOnlyForDeploymentPostprocessing = 0; 279 | }; 280 | /* End PBXResourcesBuildPhase section */ 281 | 282 | /* Begin PBXSourcesBuildPhase section */ 283 | 711A09282AC971CD0013EB44 /* Sources */ = { 284 | isa = PBXSourcesBuildPhase; 285 | buildActionMask = 2147483647; 286 | files = ( 287 | 711A09662AC972510013EB44 /* SampleViewController.swift in Sources */, 288 | 711A09642AC971FF0013EB44 /* PlainViewController.swift in Sources */, 289 | 711A09302AC971CD0013EB44 /* AppDelegate.swift in Sources */, 290 | 711A096C2AC9BAD50013EB44 /* SampleModels.swift in Sources */, 291 | 711A09322AC971CD0013EB44 /* SceneDelegate.swift in Sources */, 292 | ); 293 | runOnlyForDeploymentPostprocessing = 0; 294 | }; 295 | 711A093E2AC971CE0013EB44 /* Sources */ = { 296 | isa = PBXSourcesBuildPhase; 297 | buildActionMask = 2147483647; 298 | files = ( 299 | 711A09472AC971CE0013EB44 /* SampleTests.swift in Sources */, 300 | ); 301 | runOnlyForDeploymentPostprocessing = 0; 302 | }; 303 | 711A09482AC971CE0013EB44 /* Sources */ = { 304 | isa = PBXSourcesBuildPhase; 305 | buildActionMask = 2147483647; 306 | files = ( 307 | 711A09532AC971CE0013EB44 /* SampleUITestsLaunchTests.swift in Sources */, 308 | 711A09512AC971CE0013EB44 /* SampleUITests.swift in Sources */, 309 | ); 310 | runOnlyForDeploymentPostprocessing = 0; 311 | }; 312 | /* End PBXSourcesBuildPhase section */ 313 | 314 | /* Begin PBXTargetDependency section */ 315 | 711A09442AC971CE0013EB44 /* PBXTargetDependency */ = { 316 | isa = PBXTargetDependency; 317 | target = 711A092B2AC971CD0013EB44 /* Sample */; 318 | targetProxy = 711A09432AC971CE0013EB44 /* PBXContainerItemProxy */; 319 | }; 320 | 711A094E2AC971CE0013EB44 /* PBXTargetDependency */ = { 321 | isa = PBXTargetDependency; 322 | target = 711A092B2AC971CD0013EB44 /* Sample */; 323 | targetProxy = 711A094D2AC971CE0013EB44 /* PBXContainerItemProxy */; 324 | }; 325 | /* End PBXTargetDependency section */ 326 | 327 | /* Begin PBXVariantGroup section */ 328 | 711A093A2AC971CE0013EB44 /* LaunchScreen.storyboard */ = { 329 | isa = PBXVariantGroup; 330 | children = ( 331 | 711A093B2AC971CE0013EB44 /* Base */, 332 | ); 333 | name = LaunchScreen.storyboard; 334 | sourceTree = ""; 335 | }; 336 | /* End PBXVariantGroup section */ 337 | 338 | /* Begin XCBuildConfiguration section */ 339 | 711A09542AC971CE0013EB44 /* Debug */ = { 340 | isa = XCBuildConfiguration; 341 | buildSettings = { 342 | ALWAYS_SEARCH_USER_PATHS = NO; 343 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 344 | CLANG_ANALYZER_NONNULL = YES; 345 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 346 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 347 | CLANG_ENABLE_MODULES = YES; 348 | CLANG_ENABLE_OBJC_ARC = YES; 349 | CLANG_ENABLE_OBJC_WEAK = YES; 350 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 351 | CLANG_WARN_BOOL_CONVERSION = YES; 352 | CLANG_WARN_COMMA = YES; 353 | CLANG_WARN_CONSTANT_CONVERSION = YES; 354 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 355 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 356 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 357 | CLANG_WARN_EMPTY_BODY = YES; 358 | CLANG_WARN_ENUM_CONVERSION = YES; 359 | CLANG_WARN_INFINITE_RECURSION = YES; 360 | CLANG_WARN_INT_CONVERSION = YES; 361 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 362 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 363 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 364 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 365 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 366 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 367 | CLANG_WARN_STRICT_PROTOTYPES = YES; 368 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 369 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 370 | CLANG_WARN_UNREACHABLE_CODE = YES; 371 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 372 | COPY_PHASE_STRIP = NO; 373 | DEBUG_INFORMATION_FORMAT = dwarf; 374 | ENABLE_STRICT_OBJC_MSGSEND = YES; 375 | ENABLE_TESTABILITY = YES; 376 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 377 | GCC_C_LANGUAGE_STANDARD = gnu17; 378 | GCC_DYNAMIC_NO_PIC = NO; 379 | GCC_NO_COMMON_BLOCKS = YES; 380 | GCC_OPTIMIZATION_LEVEL = 0; 381 | GCC_PREPROCESSOR_DEFINITIONS = ( 382 | "DEBUG=1", 383 | "$(inherited)", 384 | ); 385 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 386 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 387 | GCC_WARN_UNDECLARED_SELECTOR = YES; 388 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 389 | GCC_WARN_UNUSED_FUNCTION = YES; 390 | GCC_WARN_UNUSED_VARIABLE = YES; 391 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 392 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 393 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 394 | MTL_FAST_MATH = YES; 395 | ONLY_ACTIVE_ARCH = YES; 396 | SDKROOT = iphoneos; 397 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 398 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 399 | }; 400 | name = Debug; 401 | }; 402 | 711A09552AC971CE0013EB44 /* Release */ = { 403 | isa = XCBuildConfiguration; 404 | buildSettings = { 405 | ALWAYS_SEARCH_USER_PATHS = NO; 406 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 407 | CLANG_ANALYZER_NONNULL = YES; 408 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 409 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 410 | CLANG_ENABLE_MODULES = YES; 411 | CLANG_ENABLE_OBJC_ARC = YES; 412 | CLANG_ENABLE_OBJC_WEAK = YES; 413 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 414 | CLANG_WARN_BOOL_CONVERSION = YES; 415 | CLANG_WARN_COMMA = YES; 416 | CLANG_WARN_CONSTANT_CONVERSION = YES; 417 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 418 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 419 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 420 | CLANG_WARN_EMPTY_BODY = YES; 421 | CLANG_WARN_ENUM_CONVERSION = YES; 422 | CLANG_WARN_INFINITE_RECURSION = YES; 423 | CLANG_WARN_INT_CONVERSION = YES; 424 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 425 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 426 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 427 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 428 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 429 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 430 | CLANG_WARN_STRICT_PROTOTYPES = YES; 431 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 432 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 433 | CLANG_WARN_UNREACHABLE_CODE = YES; 434 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 435 | COPY_PHASE_STRIP = NO; 436 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 437 | ENABLE_NS_ASSERTIONS = NO; 438 | ENABLE_STRICT_OBJC_MSGSEND = YES; 439 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 440 | GCC_C_LANGUAGE_STANDARD = gnu17; 441 | GCC_NO_COMMON_BLOCKS = YES; 442 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 443 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 444 | GCC_WARN_UNDECLARED_SELECTOR = YES; 445 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 446 | GCC_WARN_UNUSED_FUNCTION = YES; 447 | GCC_WARN_UNUSED_VARIABLE = YES; 448 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 449 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 450 | MTL_ENABLE_DEBUG_INFO = NO; 451 | MTL_FAST_MATH = YES; 452 | SDKROOT = iphoneos; 453 | SWIFT_COMPILATION_MODE = wholemodule; 454 | VALIDATE_PRODUCT = YES; 455 | }; 456 | name = Release; 457 | }; 458 | 711A09572AC971CE0013EB44 /* Debug */ = { 459 | isa = XCBuildConfiguration; 460 | buildSettings = { 461 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 462 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 463 | CODE_SIGN_STYLE = Automatic; 464 | CURRENT_PROJECT_VERSION = 1; 465 | GENERATE_INFOPLIST_FILE = YES; 466 | INFOPLIST_FILE = Sample/Info.plist; 467 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 468 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen.storyboard; 469 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 470 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 471 | LD_RUNPATH_SEARCH_PATHS = ( 472 | "$(inherited)", 473 | "@executable_path/Frameworks", 474 | ); 475 | MARKETING_VERSION = 1.0; 476 | PRODUCT_BUNDLE_IDENTIFIER = com.kanghoon.Sample; 477 | PRODUCT_NAME = "$(TARGET_NAME)"; 478 | SWIFT_EMIT_LOC_STRINGS = YES; 479 | SWIFT_VERSION = 5.0; 480 | TARGETED_DEVICE_FAMILY = "1,2"; 481 | }; 482 | name = Debug; 483 | }; 484 | 711A09582AC971CE0013EB44 /* Release */ = { 485 | isa = XCBuildConfiguration; 486 | buildSettings = { 487 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 488 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 489 | CODE_SIGN_STYLE = Automatic; 490 | CURRENT_PROJECT_VERSION = 1; 491 | GENERATE_INFOPLIST_FILE = YES; 492 | INFOPLIST_FILE = Sample/Info.plist; 493 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 494 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen.storyboard; 495 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 496 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 497 | LD_RUNPATH_SEARCH_PATHS = ( 498 | "$(inherited)", 499 | "@executable_path/Frameworks", 500 | ); 501 | MARKETING_VERSION = 1.0; 502 | PRODUCT_BUNDLE_IDENTIFIER = com.kanghoon.Sample; 503 | PRODUCT_NAME = "$(TARGET_NAME)"; 504 | SWIFT_EMIT_LOC_STRINGS = YES; 505 | SWIFT_VERSION = 5.0; 506 | TARGETED_DEVICE_FAMILY = "1,2"; 507 | }; 508 | name = Release; 509 | }; 510 | 711A095A2AC971CE0013EB44 /* Debug */ = { 511 | isa = XCBuildConfiguration; 512 | buildSettings = { 513 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 514 | BUNDLE_LOADER = "$(TEST_HOST)"; 515 | CODE_SIGN_STYLE = Automatic; 516 | CURRENT_PROJECT_VERSION = 1; 517 | GENERATE_INFOPLIST_FILE = YES; 518 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 519 | MARKETING_VERSION = 1.0; 520 | PRODUCT_BUNDLE_IDENTIFIER = com.kanghoon.SampleTests; 521 | PRODUCT_NAME = "$(TARGET_NAME)"; 522 | SWIFT_EMIT_LOC_STRINGS = NO; 523 | SWIFT_VERSION = 5.0; 524 | TARGETED_DEVICE_FAMILY = "1,2"; 525 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Sample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Sample"; 526 | }; 527 | name = Debug; 528 | }; 529 | 711A095B2AC971CE0013EB44 /* Release */ = { 530 | isa = XCBuildConfiguration; 531 | buildSettings = { 532 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 533 | BUNDLE_LOADER = "$(TEST_HOST)"; 534 | CODE_SIGN_STYLE = Automatic; 535 | CURRENT_PROJECT_VERSION = 1; 536 | GENERATE_INFOPLIST_FILE = YES; 537 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 538 | MARKETING_VERSION = 1.0; 539 | PRODUCT_BUNDLE_IDENTIFIER = com.kanghoon.SampleTests; 540 | PRODUCT_NAME = "$(TARGET_NAME)"; 541 | SWIFT_EMIT_LOC_STRINGS = NO; 542 | SWIFT_VERSION = 5.0; 543 | TARGETED_DEVICE_FAMILY = "1,2"; 544 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Sample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Sample"; 545 | }; 546 | name = Release; 547 | }; 548 | 711A095D2AC971CE0013EB44 /* Debug */ = { 549 | isa = XCBuildConfiguration; 550 | buildSettings = { 551 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 552 | CODE_SIGN_STYLE = Automatic; 553 | CURRENT_PROJECT_VERSION = 1; 554 | GENERATE_INFOPLIST_FILE = YES; 555 | MARKETING_VERSION = 1.0; 556 | PRODUCT_BUNDLE_IDENTIFIER = com.kanghoon.SampleUITests; 557 | PRODUCT_NAME = "$(TARGET_NAME)"; 558 | SWIFT_EMIT_LOC_STRINGS = NO; 559 | SWIFT_VERSION = 5.0; 560 | TARGETED_DEVICE_FAMILY = "1,2"; 561 | TEST_TARGET_NAME = Sample; 562 | }; 563 | name = Debug; 564 | }; 565 | 711A095E2AC971CE0013EB44 /* Release */ = { 566 | isa = XCBuildConfiguration; 567 | buildSettings = { 568 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 569 | CODE_SIGN_STYLE = Automatic; 570 | CURRENT_PROJECT_VERSION = 1; 571 | GENERATE_INFOPLIST_FILE = YES; 572 | MARKETING_VERSION = 1.0; 573 | PRODUCT_BUNDLE_IDENTIFIER = com.kanghoon.SampleUITests; 574 | PRODUCT_NAME = "$(TARGET_NAME)"; 575 | SWIFT_EMIT_LOC_STRINGS = NO; 576 | SWIFT_VERSION = 5.0; 577 | TARGETED_DEVICE_FAMILY = "1,2"; 578 | TEST_TARGET_NAME = Sample; 579 | }; 580 | name = Release; 581 | }; 582 | /* End XCBuildConfiguration section */ 583 | 584 | /* Begin XCConfigurationList section */ 585 | 711A09272AC971CD0013EB44 /* Build configuration list for PBXProject "Sample" */ = { 586 | isa = XCConfigurationList; 587 | buildConfigurations = ( 588 | 711A09542AC971CE0013EB44 /* Debug */, 589 | 711A09552AC971CE0013EB44 /* Release */, 590 | ); 591 | defaultConfigurationIsVisible = 0; 592 | defaultConfigurationName = Release; 593 | }; 594 | 711A09562AC971CE0013EB44 /* Build configuration list for PBXNativeTarget "Sample" */ = { 595 | isa = XCConfigurationList; 596 | buildConfigurations = ( 597 | 711A09572AC971CE0013EB44 /* Debug */, 598 | 711A09582AC971CE0013EB44 /* Release */, 599 | ); 600 | defaultConfigurationIsVisible = 0; 601 | defaultConfigurationName = Release; 602 | }; 603 | 711A09592AC971CE0013EB44 /* Build configuration list for PBXNativeTarget "SampleTests" */ = { 604 | isa = XCConfigurationList; 605 | buildConfigurations = ( 606 | 711A095A2AC971CE0013EB44 /* Debug */, 607 | 711A095B2AC971CE0013EB44 /* Release */, 608 | ); 609 | defaultConfigurationIsVisible = 0; 610 | defaultConfigurationName = Release; 611 | }; 612 | 711A095C2AC971CE0013EB44 /* Build configuration list for PBXNativeTarget "SampleUITests" */ = { 613 | isa = XCConfigurationList; 614 | buildConfigurations = ( 615 | 711A095D2AC971CE0013EB44 /* Debug */, 616 | 711A095E2AC971CE0013EB44 /* Release */, 617 | ); 618 | defaultConfigurationIsVisible = 0; 619 | defaultConfigurationName = Release; 620 | }; 621 | /* End XCConfigurationList section */ 622 | 623 | /* Begin XCLocalSwiftPackageReference section */ 624 | 711A095F2AC971DC0013EB44 /* XCLocalSwiftPackageReference ".." */ = { 625 | isa = XCLocalSwiftPackageReference; 626 | relativePath = ..; 627 | }; 628 | /* End XCLocalSwiftPackageReference section */ 629 | 630 | /* Begin XCSwiftPackageProductDependency section */ 631 | 711A09602AC971DC0013EB44 /* FloatingBottomSheet */ = { 632 | isa = XCSwiftPackageProductDependency; 633 | productName = FloatingBottomSheet; 634 | }; 635 | /* End XCSwiftPackageProductDependency section */ 636 | }; 637 | rootObject = 711A09242AC971CD0013EB44 /* Project object */; 638 | } 639 | -------------------------------------------------------------------------------- /Sample/Sample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sample/Sample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sample/Sample.xcodeproj/xcshareddata/xcschemes/Sample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 35 | 41 | 42 | 43 | 46 | 52 | 53 | 54 | 55 | 56 | 66 | 68 | 74 | 75 | 76 | 77 | 83 | 85 | 91 | 92 | 93 | 94 | 96 | 97 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /Sample/Sample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Sample 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | @main 11 | final class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | func application( 14 | _ application: UIApplication, 15 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 16 | ) -> Bool { 17 | // Override point for customization after application launch. 18 | true 19 | } 20 | 21 | // MARK: UISceneSession Lifecycle 22 | 23 | func application( 24 | _ application: UIApplication, 25 | configurationForConnecting connectingSceneSession: UISceneSession, 26 | options: UIScene.ConnectionOptions 27 | ) -> UISceneConfiguration { 28 | // Called when a new scene session is being created. 29 | // Use this method to select a configuration to create the new scene with. 30 | UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sample/Sample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sample/Sample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sample/Sample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sample/Sample/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sample/Sample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Sample/Sample/Root/SampleModels.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SampleModels.swift 3 | // Sample 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import Foundation 9 | 10 | import FloatingBottomSheet 11 | 12 | protocol SampleViewModel { 13 | var title: String { get } 14 | var rowViewController: FloatingBottomSheet { get } 15 | } 16 | 17 | enum SampleRow: Int, CaseIterable { 18 | case plain 19 | 20 | var viewModel: SampleViewModel { 21 | switch self { 22 | case .plain: return PlainViewModel() 23 | } 24 | } 25 | } 26 | 27 | extension SampleRow { 28 | 29 | struct PlainViewModel: SampleViewModel { 30 | let title = "Plain" 31 | let rowViewController: FloatingBottomSheet = PlainViewController() 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /Sample/Sample/Root/SampleViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SampleViewController.swift 3 | // Sample 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | import FloatingBottomSheet 11 | 12 | final class SampleViewController: UITableViewController { 13 | 14 | override func viewDidLoad() { 15 | super.viewDidLoad() 16 | setupView() 17 | } 18 | 19 | private func setupView() { 20 | title = "FloatingBottomSheet" 21 | 22 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: String(describing: UITableViewCell.self)) 23 | tableView.tableFooterView = UIView() 24 | tableView.separatorInset = .zero 25 | } 26 | 27 | override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 28 | 60.0 29 | } 30 | 31 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 32 | SampleRow.allCases.count 33 | } 34 | 35 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 36 | let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UITableViewCell.self), for: indexPath) 37 | 38 | guard let rowType = SampleRow(rawValue: indexPath.row) else { 39 | return cell 40 | } 41 | cell.textLabel?.textAlignment = .center 42 | cell.textLabel?.text = rowType.viewModel.title 43 | cell.textLabel?.font = UIFont.preferredFont(forTextStyle: .body) 44 | return cell 45 | } 46 | 47 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 48 | tableView.deselectRow(at: indexPath, animated: true) 49 | 50 | guard let rowType = SampleRow(rawValue: indexPath.row) else { 51 | return 52 | } 53 | dismiss(animated: true, completion: nil) 54 | presentFloatingBottomSheet(rowType.viewModel.rowViewController, completion: nil) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sample/Sample/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // Sample 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | 12 | var window: UIWindow? 13 | 14 | func scene( 15 | _ scene: UIScene, 16 | willConnectTo session: UISceneSession, 17 | options connectionOptions: UIScene.ConnectionOptions 18 | ) { 19 | guard let windowScene = (scene as? UIWindowScene) else { return } 20 | 21 | window = UIWindow(windowScene: windowScene) 22 | window?.rootViewController = UINavigationController(rootViewController: SampleViewController()) 23 | window?.makeKeyAndVisible() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sample/Sample/ViewControllers/PlainViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlainViewController.swift 3 | // Sample 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | import FloatingBottomSheet 11 | 12 | final class PlainViewController: UIViewController, FloatingBottomSheetPresentable { 13 | 14 | private let scrollView = UIScrollView() 15 | 16 | private let stackView: UIStackView = { 17 | let stackView = UIStackView() 18 | stackView.axis = .vertical 19 | stackView.spacing = 10.0 20 | stackView.distribution = .fill 21 | return stackView 22 | }() 23 | 24 | var bottomSheetHeight: CGFloat = 200 25 | 26 | var bottomSheetScrollable: UIScrollView? { 27 | scrollView 28 | } 29 | 30 | override func viewDidLoad() { 31 | super.viewDidLoad() 32 | view.backgroundColor = .systemBackground 33 | 34 | addSubviews() 35 | configureConstraint() 36 | } 37 | 38 | private func addSubviews() { 39 | view.addSubview(scrollView) 40 | scrollView.addSubview(stackView) 41 | 42 | let increaseButton = makeButton(title: "+ Increase Height", color: .systemBlue) { [weak self] in 43 | self?.bottomSheetHeight += 100 44 | self?.bottomSheetPerformLayout(animated: true) 45 | } 46 | let decreaseButton = makeButton(title: "- Decrease Height", color: .systemRed) { [weak self] in 47 | self?.bottomSheetHeight -= 100 48 | self?.bottomSheetPerformLayout(animated: true) 49 | } 50 | stackView.addArrangedSubview(increaseButton) 51 | stackView.addArrangedSubview(decreaseButton) 52 | 53 | (0...3).forEach { _ in 54 | stackView.addArrangedSubview(makeLabel()) 55 | } 56 | } 57 | 58 | private func makeButton(title: String, color: UIColor, handler: @escaping () -> Void) -> UIButton { 59 | let button = UIButton(configuration: .filled()) 60 | button.configuration?.baseBackgroundColor = color 61 | button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) 62 | button.titleLabel?.adjustsFontForContentSizeCategory = true 63 | button.setTitleColor(.white, for: .normal) 64 | button.setTitle(title, for: .normal) 65 | button.addAction(UIAction(handler: { _ in handler() }), for: .touchUpInside) 66 | return button 67 | } 68 | 69 | private func makeLabel() -> UILabel { 70 | let label = UILabel() 71 | label.font = UIFont.preferredFont(forTextStyle: .body) 72 | label.adjustsFontForContentSizeCategory = true 73 | label.numberOfLines = 0 74 | label.text = """ 75 | Lorem ipsum dolor sit amet consectetur adipisicing elit. Laborum minima voluptas officia eaque eveniet cupiditate dolores exercitationem soluta consequuntur rem blanditiis, odit delectus assumenda, beatae aliquam quidem voluptate nemo veniam. 76 | """ 77 | label.backgroundColor = .systemGray6 78 | return label 79 | } 80 | 81 | private func configureConstraint() { 82 | scrollView.translatesAutoresizingMaskIntoConstraints = false 83 | NSLayoutConstraint.activate([ 84 | scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), 85 | scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), 86 | scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), 87 | scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), 88 | ]) 89 | 90 | stackView.translatesAutoresizingMaskIntoConstraints = false 91 | NSLayoutConstraint.activate([ 92 | stackView.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 16), 93 | stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16), 94 | stackView.trailingAnchor .constraint(equalTo: scrollView.trailingAnchor, constant: -16), 95 | stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -24), 96 | stackView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32), 97 | ]) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sample/SampleTests/SampleTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SampleTests.swift 3 | // SampleTests 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import XCTest 9 | @testable import Sample 10 | 11 | final class SampleTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | // Any test you write for XCTest can be annotated as throws and async. 25 | // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. 26 | // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. 27 | } 28 | 29 | func testPerformanceExample() throws { 30 | // This is an example of a performance test case. 31 | measure { 32 | // Put the code you want to measure the time of here. 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Sample/SampleUITests/SampleUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SampleUITests.swift 3 | // SampleUITests 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import XCTest 9 | 10 | final class SampleUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use XCTAssert and related functions to verify your tests produce the correct results. 31 | } 32 | 33 | func testLaunchPerformance() throws { 34 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 35 | // This measures how long it takes to launch your application. 36 | measure(metrics: [XCTApplicationLaunchMetric()]) { 37 | XCUIApplication().launch() 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sample/SampleUITests/SampleUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SampleUITestsLaunchTests.swift 3 | // SampleUITests 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import XCTest 9 | 10 | final class SampleUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | func testLaunch() throws { 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Insert steps here to perform after app launch but before taking a screenshot, 25 | // such as logging into a test account or navigating somewhere in the app 26 | 27 | let attachment = XCTAttachment(screenshot: app.screenshot()) 28 | attachment.name = "Launch Screen" 29 | attachment.lifetime = .keepAlways 30 | add(attachment) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/FloatingBottomSheet/Animator/FloatingBottomSheetAnimator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingBottomSheetAnimator.swift 3 | // 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | enum FloatingBottomSheetAnimator { 11 | 12 | static func animate( 13 | _ animations: @escaping () -> Void, 14 | _ completion: ((Bool) -> Void)? = nil 15 | ) { 16 | UIView.animate( 17 | withDuration: 0.5, 18 | delay: 0, 19 | usingSpringWithDamping: 0.8, 20 | initialSpringVelocity: 0, 21 | options: [.curveEaseInOut, .allowUserInteraction, .beginFromCurrentState], 22 | animations: animations, 23 | completion: completion 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/FloatingBottomSheet/Animator/FloatingBottomSheetPresentationAnimator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingBottomSheetPresentationAnimator.swift 3 | // 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | public final class FloatingBottomSheetPresentationAnimator: NSObject { 11 | 12 | public enum TransitionDirection { 13 | case present 14 | case dismiss 15 | } 16 | 17 | 18 | // MARK: Properties 19 | 20 | private let transitionDirection: TransitionDirection 21 | 22 | private var feedbackGenerator: UISelectionFeedbackGenerator? 23 | 24 | 25 | // MARK: Initializing 26 | 27 | public init(transitionDirection: TransitionDirection) { 28 | self.transitionDirection = transitionDirection 29 | super.init() 30 | 31 | if case .present = transitionDirection { 32 | self.feedbackGenerator = UISelectionFeedbackGenerator() 33 | feedbackGenerator?.prepare() 34 | } 35 | } 36 | } 37 | 38 | 39 | // MARK: - UIViewControllerAnimatedTransitioning 40 | 41 | extension FloatingBottomSheetPresentationAnimator: UIViewControllerAnimatedTransitioning { 42 | 43 | public func transitionDuration( 44 | using transitionContext: UIViewControllerContextTransitioning? 45 | ) -> TimeInterval { 46 | 0.5 47 | } 48 | 49 | public func animateTransition( 50 | using transitionContext: UIViewControllerContextTransitioning 51 | ) { 52 | switch transitionDirection { 53 | case .present: 54 | presentTransition(transitionContext: transitionContext) 55 | case .dismiss: 56 | dismissTransition(transitionContext: transitionContext) 57 | } 58 | } 59 | } 60 | 61 | 62 | // MARK: - Private 63 | 64 | extension FloatingBottomSheetPresentationAnimator { 65 | 66 | private func presentTransition(transitionContext: UIViewControllerContextTransitioning) { 67 | guard let toViewController = transitionContext.viewController(forKey: .to) else { 68 | return 69 | } 70 | 71 | let presentable = toViewController as? FloatingBottomSheet 72 | 73 | 74 | let yOffset = presentable?.topYPosition ?? 0.0 75 | 76 | let bottomSheetContainerView: UIView = transitionContext.containerView.bottomSheetContainerView 77 | ?? toViewController.view 78 | 79 | bottomSheetContainerView.frame = transitionContext.finalFrame(for: toViewController) 80 | bottomSheetContainerView.frame.origin.y = transitionContext.containerView.frame.height 81 | 82 | feedbackGenerator?.selectionChanged() 83 | 84 | FloatingBottomSheetAnimator.animate({ 85 | bottomSheetContainerView.frame.origin.y = yOffset 86 | }) { [weak self] isCompleted in 87 | transitionContext.completeTransition(isCompleted) 88 | self?.feedbackGenerator = nil 89 | } 90 | } 91 | 92 | private func dismissTransition(transitionContext: UIViewControllerContextTransitioning) { 93 | guard let fromViewController = transitionContext.viewController(forKey: .from) else { 94 | return 95 | } 96 | 97 | let bottomSheetContainerView: UIView = transitionContext.containerView.bottomSheetContainerView 98 | ?? fromViewController.view 99 | 100 | FloatingBottomSheetAnimator.animate({ 101 | bottomSheetContainerView.frame.origin.y = transitionContext.containerView.frame.height 102 | }) { isCompleted in 103 | fromViewController.view.removeFromSuperview() 104 | transitionContext.completeTransition(isCompleted) 105 | } 106 | } 107 | } 108 | 109 | extension UIView { 110 | 111 | fileprivate var bottomSheetContainerView: FloatingBottomSheetContainerView? { 112 | subviews.first(where: { view -> Bool in 113 | view is FloatingBottomSheetContainerView 114 | }) as? FloatingBottomSheetContainerView 115 | } 116 | } 117 | 118 | -------------------------------------------------------------------------------- /Sources/FloatingBottomSheet/Presentable/FloatingBottomSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingBottomSheet.swift 3 | // 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | /// A type that can be presented as a floating bottom sheet. 11 | public typealias FloatingBottomSheet = FloatingBottomSheetPresentable & UIViewController 12 | -------------------------------------------------------------------------------- /Sources/FloatingBottomSheet/Presentable/FloatingBottomSheetPresentable+Default.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingBottomSheetPresentable+Default.swift 3 | // 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | extension FloatingBottomSheetPresentable where Self: UIViewController { 11 | 12 | public var bottomSheetInsets: NSDirectionalEdgeInsets { 13 | .init( 14 | top: safeAreaInsets.top + 42.0, 15 | leading: 16.0, 16 | bottom: safeAreaInsets.bottom + 8.0, 17 | trailing: 16.0 18 | ) 19 | } 20 | 21 | public var bottomSheetHeight: CGFloat { 22 | guard let scrollView = bottomSheetScrollable else { return 100 } 23 | scrollView.layoutIfNeeded() 24 | return scrollView.contentSize.height 25 | } 26 | 27 | public var bottomSheetCornerRadius: CGFloat { 28 | 20 29 | } 30 | 31 | public var bottomSheetDimColor: UIColor { 32 | UIColor.black.withAlphaComponent(0.5) 33 | } 34 | 35 | public var bottomSheetHandleColor: UIColor { 36 | UIColor(lightHex: "#EAEBEE", darkHex: "#34373D") 37 | } 38 | 39 | public var allowsDragToDismiss: Bool { true } 40 | 41 | public var allowsTapToDismiss: Bool { true } 42 | 43 | public func shouldRespond(to panGestureRecognizer: UIPanGestureRecognizer) -> Bool { 44 | true 45 | } 46 | 47 | public func willRespond(to panGestureRecognizer: UIPanGestureRecognizer) {} 48 | 49 | public func shouldPrioritize(panGestureRecognizer: UIPanGestureRecognizer) -> Bool { 50 | false 51 | } 52 | 53 | public func bottomSheetWillDismiss() {} 54 | 55 | public func bottomSheetDidDismiss() {} 56 | } 57 | 58 | 59 | // MARK: - Private 60 | 61 | extension FloatingBottomSheetPresentable { 62 | 63 | var safeAreaInsets: UIEdgeInsets { 64 | guard let rootViewController else { return .zero } 65 | return rootViewController.view.safeAreaInsets 66 | } 67 | 68 | private var keyWindow: UIWindow? { 69 | if #available(iOS 15.0, *) { 70 | return UIApplication.shared.connectedScenes 71 | .compactMap { ($0 as? UIWindowScene)?.keyWindow } 72 | .first 73 | } else { 74 | return UIApplication.shared.windows.first { $0.isKeyWindow } 75 | } 76 | } 77 | 78 | private var rootViewController: UIViewController? { 79 | keyWindow?.rootViewController 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/FloatingBottomSheet/Presentable/FloatingBottomSheetPresentable+Internal.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingBottomSheetPresentable+Internal.swift 3 | // 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | extension FloatingBottomSheetPresentable where Self: UIViewController { 11 | 12 | var bottomSheetPresentationController: FloatingBottomSheetPresentationController? { 13 | presentationController as? FloatingBottomSheetPresentationController 14 | } 15 | 16 | var topYPosition: CGFloat { 17 | max(topMargin(from: containerViewHeight), 0) + bottomSheetInsets.top 18 | } 19 | 20 | private var containerViewHeight: CGFloat { 21 | bottomSheetHeight 22 | + FloatingBottomSheetHandleMetric.verticalMargin * 2 23 | + FloatingBottomSheetHandleMetric.size.height 24 | } 25 | 26 | private var bottomYPosition: CGFloat { 27 | guard let containerView = bottomSheetPresentationController?.containerView 28 | else { return view.bounds.height } 29 | 30 | return containerView.bounds.size.height - bottomSheetInsets.bottom - bottomSheetInsets.top 31 | } 32 | 33 | private func topMargin(from height: CGFloat) -> CGFloat { 34 | bottomYPosition - height 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/FloatingBottomSheet/Presentable/FloatingBottomSheetPresentable+UIViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingBottomSheetPresentable+UIViewController.swift 3 | // 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | extension FloatingBottomSheetPresentable where Self: UIViewController { 11 | 12 | /// Triggers layout changes for the bottom sheet presentation controller. 13 | /// 14 | /// This method can be called to initiate layout changes for the bottom sheet presentation controller. 15 | /// The `animated` parameter determines whether the layout updates should be performed with animation. 16 | /// 17 | /// - Parameter animated: A Boolean value indicating whether the layout changes should be performed with animation. 18 | public func bottomSheetPerformLayout(animated: Bool) { 19 | bottomSheetPresentationController?.performLayout(animated: animated) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/FloatingBottomSheet/Presentable/FloatingBottomSheetPresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingBottomSheetPresentable.swift 3 | // 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | /// It is the configuration object for a view controller 11 | /// that will be presented using transition 12 | /// 13 | /// ``` 14 | /// extension ViewController: FloatingBottomSheetPresentable { 15 | /// 16 | /// var bottomSheetScrollable: UIScrollView? { 17 | /// scrollView 18 | /// } 19 | /// 20 | /// var bottomSheetHeight: CGFloat { 21 | /// scrollView.contentSize.height 22 | /// } 23 | /// } 24 | /// ``` 25 | public protocol FloatingBottomSheetPresentable: AnyObject { 26 | 27 | /// The scroll view embedded in the view controller. 28 | /// it allows for seamless transition between the embedded scroll view and bottom sheet container view. 29 | var bottomSheetScrollable: UIScrollView? { get } 30 | 31 | /// The insets between the screen and the container view for a bottom sheet. 32 | /// 33 | /// The default value for `bottomSheetInsets` is 34 | /// - top: view.safeAreaInsets.top + 42.0 35 | /// - leading: 16.0 36 | /// - trailing: 16.0 37 | /// - bottom: view.safeAreaInsets.bottom + 8.0 38 | var bottomSheetInsets: NSDirectionalEdgeInsets { get } 39 | 40 | /// The `bottomSheetHeight` property represents the height of the presented view for a bottom sheet. 41 | /// Height of the bottom sheet container view is `bottomSheetHeight` + 24 (handle view area) 42 | /// 43 | /// This property is adjusted to the maximum possible height within the screen limits. 44 | /// 45 | /// The default value for `bottomSheetHeight` is 100. 46 | var bottomSheetHeight: CGFloat { get } 47 | 48 | /// The bottom sheet corner radius 49 | /// 50 | /// The default value for `bottomSheetCornerRadius` is 20. 51 | var bottomSheetCornerRadius: CGFloat { get } 52 | 53 | /// The bottom sheet dim color 54 | /// 55 | /// The default value for `bottomSheetDimColor` is black with alpha component 0.8 56 | var bottomSheetDimColor: UIColor { get } 57 | 58 | /// The bottom sheet handle color 59 | /// 60 | /// The default value for `bottomSheetHandleColor` is `UIColor(lightHex: "#EAEBEE", darkHex: "#34373D")` 61 | var bottomSheetHandleColor: UIColor { get } 62 | 63 | /// The `allowsDragToDismiss` property determines whether the user can swipe down to dismiss the bottom sheet. 64 | /// 65 | /// If `allowsDragToDismiss` is set to `true`, the user can dismiss the bottom sheet by swiping it down. 66 | /// If set to `false`, this behavior is disabled. 67 | /// 68 | /// The default value for `allowsDragToDismiss` is `true`. 69 | var allowsDragToDismiss: Bool { get } 70 | 71 | /// The `allowsTapToDismiss` property determines whether the user can tap the dimmed background view to dismiss the bottom sheet. 72 | /// 73 | /// When `allowsTapToDismiss` is set to `true`, tapping on the dimmed background view will dismiss the bottom sheet. 74 | /// If set to `false`, this interaction is disabled. 75 | /// 76 | /// The default value for `allowsTapToDismiss` is `true`. 77 | var allowsTapToDismiss: Bool { get } 78 | 79 | /// This method is used to query the delegate about whether the bottom sheet should respond to the bottom sheet gesture recognizer. 80 | /// 81 | /// Return `false` to disable the bottom sheet's movement while keeping other gestures on the presented view intact. 82 | /// 83 | /// The default value is `true`. 84 | /// 85 | /// - Parameters: 86 | /// - panGestureRecognizer: The gesture recognizer used for bottom sheet interaction. 87 | /// - Returns: A Boolean value indicating whether the bottom sheet should respond to the bottom sheet gesture recognizer. 88 | func shouldRespond(to panGestureRecognizer: UIPanGestureRecognizer) -> Bool 89 | 90 | /// This method notifies the delegate when the bottom sheet gesture recognizer's state transitions to either `began` or `changed`. 91 | /// It provides an opportunity for the delegate to prepare for changes in the gesture recognizer's state, such as when the bottom sheet view is about to scroll. 92 | 93 | /// The default implementation is an empty method. 94 | 95 | /// - Parameter panGestureRecognizer: The gesture recognizer used for bottom sheet interaction. 96 | func willRespond(to panGestureRecognizer: UIPanGestureRecognizer) 97 | 98 | /// Asks the delegate whether the bottom sheet gesture recognizer should be given priority for the bottom sheet. 99 | /// 100 | /// For example, you can use this method to define a region where you want to restrict the starting location of the pan gesture. 101 | /// 102 | /// If you return `false`, the decision to succeed or fail the pan gesture relies solely on internal conditions, such as whether the scrollView is actively scrolling. 103 | /// 104 | /// The default return value is `false`. 105 | /// 106 | /// - Parameter panGestureRecognizer: The gesture recognizer used for bottom sheet interaction. 107 | /// - Returns: A Boolean value indicating whether the bottom sheet gesture recognizer should be prioritized. 108 | func shouldPrioritize(panGestureRecognizer: UIPanGestureRecognizer) -> Bool 109 | 110 | /// Informs the delegate that the bottom sheet is about to be dismissed. 111 | /// 112 | /// This method is called just before the bottom sheet is dismissed, giving the delegate an opportunity to perform any necessary tasks. 113 | /// 114 | /// The default behavior is an empty implementation. 115 | func bottomSheetWillDismiss() 116 | 117 | /// Informs the delegate that the bottom sheet has been dismissed. 118 | /// 119 | /// This method is called after the bottom sheet has been dismissed, allowing the delegate to perform any post-dismissal actions. 120 | /// 121 | /// The default behavior is an empty implementation. 122 | func bottomSheetDidDismiss() 123 | } 124 | -------------------------------------------------------------------------------- /Sources/FloatingBottomSheet/Presentation/FloatingBottomSheetContainerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingBottomSheetContainerView.swift 3 | // 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | final class FloatingBottomSheetContainerView: UIView { 11 | 12 | init(presentedView: UIView, frame: CGRect) { 13 | super.init(frame: frame) 14 | addSubview(presentedView) 15 | } 16 | 17 | required init?(coder: NSCoder) { 18 | fatalError("init(coder:) has not been implemented") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/FloatingBottomSheet/Presentation/FloatingBottomSheetHandleMetric.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheetHandleMetric.swift 3 | // 4 | // 5 | // Created by Ray on 3/24/24. 6 | // 7 | 8 | import UIKit 9 | 10 | enum FloatingBottomSheetHandleMetric { 11 | static let size = CGSize(width: 40, height: 4) 12 | static let verticalMargin: CGFloat = 10 13 | } 14 | -------------------------------------------------------------------------------- /Sources/FloatingBottomSheet/Presentation/FloatingBottomSheetPresentationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingBottomSheetPresentationController.swift 3 | // 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | public final class FloatingBottomSheetPresentationController: UIPresentationController { 11 | 12 | // MARK: Constants 13 | 14 | private enum Metric { 15 | 16 | enum PresentedView { 17 | static let cornerRadius: CGFloat = 20 18 | } 19 | } 20 | 21 | private enum Const { 22 | static let snapMovementSensitivity: CGFloat = 0.5 23 | } 24 | 25 | 26 | // MARK: UI 27 | 28 | private var presentable: FloatingBottomSheetPresentable? { 29 | presentedViewController as? FloatingBottomSheetPresentable 30 | } 31 | 32 | private lazy var dimmingView: UIView = { 33 | let dimmingView = UIView() 34 | dimmingView.alpha = 0 35 | dimmingView.addGestureRecognizer(tapGesture) 36 | return dimmingView 37 | }() 38 | 39 | private lazy var bottomSheetContainerView = FloatingBottomSheetContainerView( 40 | presentedView: presentedViewController.view, 41 | frame: containerView?.frame ?? .zero 42 | ) 43 | 44 | private lazy var handleView: UIView = { 45 | let view = UIView() 46 | view.backgroundColor = presentable?.bottomSheetHandleColor 47 | view.layer.cornerRadius = FloatingBottomSheetHandleMetric.size.height * 0.5 48 | return view 49 | }() 50 | 51 | public override var presentedView: UIView { 52 | bottomSheetContainerView.bringSubviewToFront(handleView) 53 | return bottomSheetContainerView 54 | } 55 | 56 | public override var frameOfPresentedViewInContainerView: CGRect { 57 | guard let containerViewFrame = containerView?.frame else { return .zero } 58 | let adjustedSize = CGSize( 59 | width: containerViewFrame.size.width - bottomSheetInsets.leading - bottomSheetInsets.trailing, 60 | height: containerViewFrame.size.height - bottomSheetInsets.bottom - topYPosition 61 | ) 62 | 63 | return CGRect( 64 | x: bottomSheetInsets.leading, 65 | y: topYPosition, 66 | width: adjustedSize.width, 67 | height: adjustedSize.height 68 | ) 69 | } 70 | 71 | // MARK: Gesture 72 | 73 | private lazy var tapGesture = UITapGestureRecognizer( 74 | target: self, 75 | action: #selector(didTapDimmingView) 76 | ) 77 | 78 | private lazy var panGestureRecognizer: UIPanGestureRecognizer = { 79 | let gesture = UIPanGestureRecognizer(target: self, action: #selector(didPanOnPresentedView(_ :))) 80 | gesture.minimumNumberOfTouches = 1 81 | gesture.maximumNumberOfTouches = 1 82 | gesture.delegate = self 83 | return gesture 84 | }() 85 | 86 | 87 | // MARK: Properties 88 | 89 | private var scrollObserver: NSKeyValueObservation? 90 | 91 | private var topYPosition: CGFloat = 0.0 92 | 93 | private var bottomSheetInsets: NSDirectionalEdgeInsets = .zero 94 | 95 | private var isPresentedViewAnimating = false 96 | 97 | private var scrollViewYOffset: CGFloat = 0.0 98 | 99 | 100 | // MARK: Initializing 101 | 102 | deinit { 103 | scrollObserver?.invalidate() 104 | } 105 | 106 | 107 | // MARK: View Life Cycle 108 | 109 | public override func containerViewWillLayoutSubviews() { 110 | super.containerViewWillLayoutSubviews() 111 | configureViewLayout() 112 | presentedView.frame = frameOfPresentedViewInContainerView 113 | presentedViewController.view.frame.size = frameOfPresentedViewInContainerView.size 114 | } 115 | 116 | public override func containerViewDidLayoutSubviews() { 117 | super.containerViewDidLayoutSubviews() 118 | adjustHandleViewFrame() 119 | adjustPresentedViewControllerTopInset() 120 | addRoundedCorners(to: bottomSheetContainerView) 121 | } 122 | 123 | 124 | // MARK: Presentation Transition 125 | 126 | public override func presentationTransitionWillBegin() { 127 | guard let containerView else { return } 128 | 129 | bottomSheetContainerView.addSubview(handleView) 130 | bottomSheetContainerView.addGestureRecognizer(panGestureRecognizer) 131 | 132 | containerView.addSubview(dimmingView) 133 | containerView.addSubview(bottomSheetContainerView) 134 | 135 | layoutDimmingView(in: containerView) 136 | performLayout(animated: false) 137 | adjustBackgroundColors() 138 | 139 | guard let coordinator = presentedViewController.transitionCoordinator else { 140 | // Calls viewWillAppear and viewWillDisappear 141 | presentingViewController.beginAppearanceTransition(false, animated: false) 142 | dimmingView.alpha = 1.0 143 | return 144 | } 145 | 146 | // Calls viewWillAppear and viewWillDisappear 147 | presentingViewController.beginAppearanceTransition(false, animated: coordinator.isAnimated) 148 | 149 | coordinator.animate(alongsideTransition: { [weak self] _ in 150 | self?.dimmingView.alpha = 1.0 151 | self?.presentedViewController.setNeedsStatusBarAppearanceUpdate() 152 | }) 153 | } 154 | 155 | public override func presentationTransitionDidEnd(_ completed: Bool) { 156 | // Calls viewDidAppear and viewDidDisappear 157 | presentingViewController.endAppearanceTransition() 158 | 159 | if !completed { 160 | dimmingView.removeFromSuperview() 161 | } 162 | } 163 | 164 | 165 | // MARK: Dismissal Transition 166 | 167 | public override func dismissalTransitionWillBegin() { 168 | presentable?.bottomSheetWillDismiss() 169 | 170 | guard let coordinator = presentedViewController.transitionCoordinator else { 171 | // Calls viewWillAppear and viewWillDisappear 172 | presentingViewController.beginAppearanceTransition(true, animated: false) 173 | dimmingView.alpha = 0.0 174 | return 175 | } 176 | 177 | // Calls viewWillAppear and viewWillDisappear 178 | presentingViewController.beginAppearanceTransition(true, animated: coordinator.isAnimated) 179 | 180 | coordinator.animate { [weak self] context in 181 | self?.dimmingView.alpha = 0.0 182 | if !context.isAnimated { 183 | self?.bottomSheetContainerView.alpha = 0.0 184 | } 185 | self?.presentingViewController.setNeedsStatusBarAppearanceUpdate() 186 | } 187 | } 188 | 189 | public override func dismissalTransitionDidEnd(_ completed: Bool) { 190 | // Calls viewDidAppear and viewDidDisappear 191 | presentingViewController.endAppearanceTransition() 192 | 193 | if completed { 194 | presentable?.bottomSheetDidDismiss() 195 | } 196 | } 197 | } 198 | 199 | 200 | // MARK: - Presented View Layout 201 | 202 | extension FloatingBottomSheetPresentationController { 203 | 204 | var isPresentedViewAnchored: Bool { 205 | if !isPresentedViewAnimating, 206 | bottomSheetContainerView.frame.minY.rounded() <= topYPosition.rounded() { 207 | return true 208 | } 209 | 210 | return false 211 | } 212 | 213 | func performLayout(animated: Bool) { 214 | if animated { 215 | FloatingBottomSheetAnimator.animate({ [weak self] in 216 | guard let self else { return } 217 | isPresentedViewAnimating = true 218 | configureViewLayout() 219 | observe(scrollView: presentable?.bottomSheetScrollable) 220 | configureScrollViewInsets() 221 | containerView?.setNeedsLayout() 222 | containerView?.layoutIfNeeded() 223 | }) { [weak self] isCompleted in 224 | self?.isPresentedViewAnimating = !isCompleted 225 | } 226 | } else { 227 | configureViewLayout() 228 | observe(scrollView: presentable?.bottomSheetScrollable) 229 | configureScrollViewInsets() 230 | containerView?.setNeedsLayout() 231 | } 232 | } 233 | 234 | private func adjustHandleViewFrame() { 235 | handleView.frame.origin.y = FloatingBottomSheetHandleMetric.verticalMargin 236 | handleView.frame.size = FloatingBottomSheetHandleMetric.size 237 | handleView.center.x = CGRectGetMidX(bottomSheetContainerView.bounds) 238 | } 239 | 240 | private func adjustPresentedViewControllerTopInset() { 241 | let topInset = handleView.frame.maxY + FloatingBottomSheetHandleMetric.verticalMargin 242 | presentedViewController.additionalSafeAreaInsets.top = topInset 243 | } 244 | 245 | private func adjustBackgroundColors() { 246 | dimmingView.backgroundColor = presentable?.bottomSheetDimColor 247 | 248 | handleView.backgroundColor = presentable?.bottomSheetHandleColor 249 | 250 | bottomSheetContainerView.backgroundColor = presentedViewController.view.backgroundColor 251 | ?? presentable?.bottomSheetScrollable?.backgroundColor 252 | } 253 | 254 | private func layoutDimmingView(in containerView: UIView) { 255 | dimmingView.translatesAutoresizingMaskIntoConstraints = false 256 | NSLayoutConstraint.activate([ 257 | dimmingView.topAnchor.constraint(equalTo: containerView.topAnchor), 258 | dimmingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), 259 | dimmingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), 260 | dimmingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), 261 | ]) 262 | } 263 | 264 | private func configureViewLayout() { 265 | guard let viewControllable = presentedViewController as? FloatingBottomSheet 266 | else { return } 267 | 268 | topYPosition = viewControllable.topYPosition 269 | bottomSheetInsets = viewControllable.bottomSheetInsets 270 | } 271 | 272 | private func configureScrollViewInsets() { 273 | guard let scrollView = presentable?.bottomSheetScrollable, 274 | !scrollView.isScrolling 275 | else { return } 276 | 277 | scrollView.showsVerticalScrollIndicator = false 278 | scrollView.contentInsetAdjustmentBehavior = .never 279 | } 280 | 281 | private func addRoundedCorners(to view: UIView) { 282 | view.layer.cornerRadius = presentable?.bottomSheetCornerRadius ?? Metric.PresentedView.cornerRadius 283 | view.layer.masksToBounds = true 284 | } 285 | } 286 | 287 | 288 | // MARK: - Gesture 289 | 290 | extension FloatingBottomSheetPresentationController { 291 | 292 | @objc 293 | private func didTapDimmingView() { 294 | guard presentable?.allowsTapToDismiss == true else { return } 295 | presentedViewController.dismiss(animated: true) 296 | } 297 | 298 | @objc 299 | private func didPanOnPresentedView(_ recognizer: UIPanGestureRecognizer) { 300 | guard shouldRespond(to: recognizer), 301 | let containerView 302 | else { 303 | recognizer.setTranslation(.zero, in: recognizer.view) 304 | return 305 | } 306 | 307 | switch recognizer.state { 308 | case .began, .changed: 309 | respond(to: recognizer) 310 | 311 | default: 312 | let velocity = recognizer.velocity(in: bottomSheetContainerView) 313 | 314 | if isVelocityWithinSensitivityRange(velocity.y) { 315 | if bottomSheetContainerView.frame.minY < topYPosition || presentable?.allowsDragToDismiss == false { 316 | snap(toYPosition: topYPosition) 317 | } else { 318 | presentedViewController.dismiss(animated: true) 319 | } 320 | 321 | } else { 322 | let position = nearest( 323 | to: bottomSheetContainerView.frame.minY, 324 | inValues: [containerView.bounds.height, topYPosition] 325 | ) 326 | 327 | if position == topYPosition || presentable?.allowsDragToDismiss == false { 328 | snap(toYPosition: topYPosition) 329 | } else { 330 | presentedViewController.dismiss(animated: true) 331 | } 332 | } 333 | } 334 | } 335 | 336 | func shouldRespond(to panGestureRecognizer: UIPanGestureRecognizer) -> Bool { 337 | guard presentable?.shouldRespond(to: panGestureRecognizer) == true || 338 | !(panGestureRecognizer.state == .began || panGestureRecognizer.state == .cancelled) 339 | else { 340 | panGestureRecognizer.isEnabled = false 341 | panGestureRecognizer.isEnabled = true 342 | return false 343 | } 344 | return !shouldFail(panGestureRecognizer: panGestureRecognizer) 345 | } 346 | 347 | func respond(to panGestureRecognizer: UIPanGestureRecognizer) { 348 | presentable?.willRespond(to: panGestureRecognizer) 349 | 350 | var yDisplacement = panGestureRecognizer.translation(in: bottomSheetContainerView).y 351 | 352 | if bottomSheetContainerView.frame.origin.y < topYPosition { 353 | yDisplacement /= 2.0 354 | } 355 | adjust(toYPosition: bottomSheetContainerView.frame.origin.y + yDisplacement) 356 | 357 | panGestureRecognizer.setTranslation(.zero, in: bottomSheetContainerView) 358 | } 359 | 360 | func shouldFail(panGestureRecognizer: UIPanGestureRecognizer) -> Bool { 361 | guard !shouldPrioritize(panGestureRecognizer: panGestureRecognizer) 362 | else { 363 | presentable?.bottomSheetScrollable?.panGestureRecognizer.isEnabled = false 364 | presentable?.bottomSheetScrollable?.panGestureRecognizer.isEnabled = true 365 | return false 366 | } 367 | 368 | guard isPresentedViewAnchored, 369 | let scrollView = presentable?.bottomSheetScrollable, 370 | scrollView.contentOffset.y > 0 371 | else { 372 | return false 373 | } 374 | 375 | let location = panGestureRecognizer.location(in: bottomSheetContainerView) 376 | return scrollView.frame.contains(location) || scrollView.isScrolling 377 | } 378 | 379 | func shouldPrioritize(panGestureRecognizer: UIPanGestureRecognizer) -> Bool { 380 | panGestureRecognizer.state == .began && 381 | presentable?.shouldPrioritize(panGestureRecognizer: panGestureRecognizer) == true 382 | } 383 | 384 | func isVelocityWithinSensitivityRange(_ velocity: CGFloat) -> Bool { 385 | (velocity - (1000 * (1 - Const.snapMovementSensitivity))) > 0 386 | } 387 | 388 | func snap(toYPosition yPosition: CGFloat) { 389 | FloatingBottomSheetAnimator.animate({ [weak self] in 390 | self?.adjust(toYPosition: yPosition) 391 | self?.isPresentedViewAnimating = true 392 | }) { [weak self] isCompleted in 393 | self?.isPresentedViewAnimating = !isCompleted 394 | } 395 | } 396 | 397 | func adjust(toYPosition yPosition: CGFloat) { 398 | bottomSheetContainerView.frame.origin.y = max(yPosition, topYPosition) 399 | 400 | guard bottomSheetContainerView.frame.origin.y > topYPosition else { 401 | dimmingView.alpha = 1.0 402 | return 403 | } 404 | 405 | let yDisplacementFromShortForm = bottomSheetContainerView.frame.origin.y - topYPosition 406 | 407 | dimmingView.alpha = 1.0 - (yDisplacementFromShortForm / bottomSheetContainerView.frame.height) 408 | } 409 | 410 | func nearest(to number: CGFloat, inValues values: [CGFloat]) -> CGFloat { 411 | guard let nearestVal = values.min(by: { abs(number - $0) < abs(number - $1) }) 412 | else { return number } 413 | return nearestVal 414 | } 415 | } 416 | 417 | 418 | // MARK: - UIGestureRecognizerDelegate 419 | 420 | extension FloatingBottomSheetPresentationController: UIGestureRecognizerDelegate { 421 | 422 | public func gestureRecognizer( 423 | _ gestureRecognizer: UIGestureRecognizer, 424 | shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer 425 | ) -> Bool { 426 | false 427 | } 428 | 429 | public func gestureRecognizer( 430 | _ gestureRecognizer: UIGestureRecognizer, 431 | shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer 432 | ) -> Bool { 433 | otherGestureRecognizer.view == presentable?.bottomSheetScrollable 434 | } 435 | } 436 | 437 | 438 | // MARK: - UIScrollView Observer 439 | 440 | extension FloatingBottomSheetPresentationController { 441 | 442 | private func observe(scrollView: UIScrollView?) { 443 | scrollObserver?.invalidate() 444 | scrollObserver = scrollView?.observe(\.contentOffset, options: .old) { [weak self] scrollView, change in 445 | guard self?.containerView != nil else { return } 446 | 447 | self?.didPanOnScrollView(scrollView, change: change) 448 | } 449 | } 450 | 451 | private func didPanOnScrollView( 452 | _ scrollView: UIScrollView, 453 | change: NSKeyValueObservedChange 454 | ) { 455 | guard !presentedViewController.isBeingDismissed, 456 | !presentedViewController.isBeingPresented 457 | else { return } 458 | 459 | if !isPresentedViewAnchored, scrollView.contentOffset.y > 0 { 460 | haltScrolling(scrollView) 461 | return 462 | } 463 | 464 | if scrollView.isScrolling || isPresentedViewAnimating { 465 | if isPresentedViewAnchored { 466 | trackScrolling(scrollView) 467 | } else { 468 | haltScrolling(scrollView) 469 | } 470 | return 471 | } 472 | 473 | if presentedViewController.view.isKind(of: UIScrollView.self), 474 | !isPresentedViewAnimating, scrollView.contentOffset.y <= 0 { 475 | handleScrollViewTopBounce(scrollView: scrollView, change: change) 476 | return 477 | } 478 | 479 | trackScrolling(scrollView) 480 | } 481 | 482 | private func haltScrolling(_ scrollView: UIScrollView) { 483 | scrollView.setContentOffset(CGPoint(x: 0, y: scrollViewYOffset), animated: false) 484 | scrollView.showsVerticalScrollIndicator = false 485 | } 486 | 487 | private func trackScrolling(_ scrollView: UIScrollView) { 488 | scrollViewYOffset = max(scrollView.contentOffset.y, 0) 489 | scrollView.showsVerticalScrollIndicator = true 490 | } 491 | 492 | private func handleScrollViewTopBounce(scrollView: UIScrollView, change: NSKeyValueObservedChange) { 493 | guard let oldYValue = change.oldValue?.y, scrollView.isDecelerating else { return } 494 | 495 | let yOffset = scrollView.contentOffset.y 496 | let presentedSize = containerView?.frame.size ?? .zero 497 | 498 | bottomSheetContainerView.bounds.size = CGSize(width: presentedSize.width, height: presentedSize.height + yOffset) 499 | 500 | if oldYValue > yOffset { 501 | bottomSheetContainerView.frame.origin.y = topYPosition - yOffset 502 | } else { 503 | scrollViewYOffset = 0 504 | snap(toYPosition: topYPosition) 505 | } 506 | 507 | scrollView.showsVerticalScrollIndicator = false 508 | } 509 | } 510 | 511 | extension UIScrollView { 512 | 513 | fileprivate var isScrolling: Bool { 514 | isDragging && !isDecelerating || isTracking 515 | } 516 | } 517 | -------------------------------------------------------------------------------- /Sources/FloatingBottomSheet/Presentation/FloatingBottomSheetPresentationDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingBottomSheetPresentationDelegate.swift 3 | // 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | public final class FloatingBottomSheetPresentationDelegate: NSObject { 11 | 12 | public static var `default` = FloatingBottomSheetPresentationDelegate() 13 | } 14 | 15 | 16 | // MARK: - UIViewControllerTransitioningDelegate 17 | 18 | extension FloatingBottomSheetPresentationDelegate: UIViewControllerTransitioningDelegate { 19 | 20 | public func animationController( 21 | forPresented presented: UIViewController, 22 | presenting: UIViewController, 23 | source: UIViewController 24 | ) -> UIViewControllerAnimatedTransitioning? { 25 | FloatingBottomSheetPresentationAnimator(transitionDirection: .present) 26 | } 27 | 28 | public func animationController( 29 | forDismissed dismissed: UIViewController 30 | ) -> UIViewControllerAnimatedTransitioning? { 31 | FloatingBottomSheetPresentationAnimator(transitionDirection: .dismiss) 32 | } 33 | 34 | public func presentationController( 35 | forPresented presented: UIViewController, 36 | presenting: UIViewController?, 37 | source: UIViewController 38 | ) -> UIPresentationController? { 39 | let controller = FloatingBottomSheetPresentationController( 40 | presentedViewController: presented, 41 | presenting: presenting 42 | ) 43 | controller.delegate = self 44 | return controller 45 | } 46 | } 47 | 48 | 49 | // MARK: - UIAdaptivePresentationControllerDelegate 50 | 51 | extension FloatingBottomSheetPresentationDelegate: UIAdaptivePresentationControllerDelegate { 52 | 53 | public func adaptivePresentationStyle( 54 | for controller: UIPresentationController, 55 | traitCollection: UITraitCollection 56 | ) -> UIModalPresentationStyle { 57 | .none 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/FloatingBottomSheet/Presenter/FloatingBottomSheetPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingBottomSheetPresenter.swift 3 | // 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | /// Protocol for presenting a floating bottom sheet. 11 | protocol FloatingBottomSheetPresenter: AnyObject { 12 | 13 | /// A boolean property indicating whether a floating bottom sheet is currently presented. 14 | var isFloatingBottomSheetPresented: Bool { get } 15 | 16 | /// Presents a floating bottom sheet. 17 | /// 18 | /// - Parameters: 19 | /// - viewControllerToPresent: The FloatingBottomSheet view controller to present. 20 | /// - completion: A closure to be executed after the presentation is complete. 21 | func presentFloatingBottomSheet( 22 | _ viewControllerToPresent: FloatingBottomSheet, 23 | completion: (() -> Void)? 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /Sources/FloatingBottomSheet/Presenter/UIViewController+FloatingBottomSheetPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewController+FloatingBottomSheetPresenter.swift 3 | // 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIViewController: FloatingBottomSheetPresenter { 11 | 12 | /// A flag that returns true if the topmost view controller in the navigation stack 13 | /// was presented using the custom FloatingBottomSheet transition. 14 | /// 15 | /// - Warning: ⚠️ Calling `transitioningDelegate` in this function may cause a memory leak. ⚠️ 16 | /// 17 | /// In most cases, this check will be used early in the view lifecycle and unfortunately, 18 | /// there's a potential issue that causes a memory leak if the `transitioningDelegate` is 19 | /// referenced here and called too early, resulting in a strong reference to this view controller. 20 | public var isFloatingBottomSheetPresented: Bool { 21 | (transitioningDelegate as? FloatingBottomSheetPresentationDelegate) != nil 22 | } 23 | 24 | /// Configures a view controller for presentation using the FloatingBottomSheet transition. 25 | /// 26 | /// - Parameters: 27 | /// - viewControllerToPresent: The view controller to be presented. 28 | /// - completion: The block to execute after the presentation finishes. You may specify nil for this parameter. 29 | /// 30 | /// The function sets the modal presentation style and related properties for the specified view controller 31 | /// to achieve a FloatingBottomSheet presentation. It also assigns the appropriate transitioning delegate 32 | /// for the presentation style. 33 | public func presentFloatingBottomSheet( 34 | _ viewControllerToPresent: FloatingBottomSheet, 35 | completion: (() -> Void)? = nil 36 | ) { 37 | viewControllerToPresent.modalPresentationStyle = .custom 38 | viewControllerToPresent.modalPresentationCapturesStatusBarAppearance = true 39 | viewControllerToPresent.transitioningDelegate = FloatingBottomSheetPresentationDelegate.default 40 | 41 | present(viewControllerToPresent, animated: true, completion: completion) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/FloatingBottomSheet/Utils/UIColor+Init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor+Init.swift 3 | // 4 | // 5 | // Created by Kanghoon Oh on 2023/10/01. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIColor { 11 | 12 | convenience init( 13 | r: Int, 14 | g: Int, 15 | b: Int, 16 | a: Int? = nil 17 | ) { 18 | self.init( 19 | red: CGFloat(r) / 255, 20 | green: CGFloat(g) / 255, 21 | blue: CGFloat(b) / 255, 22 | alpha: CGFloat(a ?? 255) / 255 23 | ) 24 | } 25 | 26 | convenience init(hex: String) { 27 | let hexSanitized = hex 28 | .trimmingCharacters(in: .whitespacesAndNewlines) 29 | .replacingOccurrences(of: "#", with: "") 30 | 31 | var rgb: UInt64 = 0 32 | 33 | var r: CGFloat = 0.0 34 | var g: CGFloat = 0.0 35 | var b: CGFloat = 0.0 36 | var a: CGFloat = 1.0 37 | 38 | let length = hexSanitized.count 39 | 40 | if Scanner(string: hexSanitized).scanHexInt64(&rgb) == true { 41 | if length == 6 { 42 | r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0 43 | g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0 44 | b = CGFloat(rgb & 0x0000FF) / 255.0 45 | 46 | } else if length == 8 { 47 | r = CGFloat((rgb & 0xFF000000) >> 24) / 255.0 48 | g = CGFloat((rgb & 0x00FF0000) >> 16) / 255.0 49 | b = CGFloat((rgb & 0x0000FF00) >> 8) / 255.0 50 | a = CGFloat(rgb & 0x000000FF) / 255.0 51 | } 52 | } 53 | 54 | self.init(red: r, green: g, blue: b, alpha: a) 55 | } 56 | 57 | convenience init(light: UIColor, dark: UIColor) { 58 | if #available(iOS 13.0, *) { 59 | self.init(dynamicProvider: { 60 | switch $0.userInterfaceStyle { 61 | case .light, .unspecified: 62 | return light 63 | case .dark: 64 | return dark 65 | @unknown default: 66 | assertionFailure("Unknown userInterfaceStyle: \($0.userInterfaceStyle)") 67 | return light 68 | } 69 | }) 70 | } else { 71 | self.init(cgColor: light.cgColor) 72 | } 73 | } 74 | 75 | convenience init(lightHex: String, darkHex: String) { 76 | if #available(iOS 13.0, *) { 77 | self.init(dynamicProvider: { 78 | switch $0.userInterfaceStyle { 79 | case .light, .unspecified: 80 | return UIColor(hex: lightHex) 81 | case .dark: 82 | return UIColor(hex: darkHex) 83 | @unknown default: 84 | assertionFailure("Unknown userInterfaceStyle: \($0.userInterfaceStyle)") 85 | return UIColor(hex: lightHex) 86 | } 87 | }) 88 | } else { 89 | self.init(hex: lightHex) 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Tests/FloatingBottomSheetTests/FloatingBottomSheetTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import FloatingBottomSheet 3 | 4 | final class FloatingBottomSheetTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documentation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /assets/floatingbottomsheet.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OhKanghoon/FloatingBottomSheet/34f7d86712b19c894b76759796aa638cb09facbf/assets/floatingbottomsheet.gif -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OhKanghoon/FloatingBottomSheet/34f7d86712b19c894b76759796aa638cb09facbf/assets/logo.png --------------------------------------------------------------------------------