diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 05c2d44fa..000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,43 +0,0 @@ -version: 2.1 - -jobs: - build-swift_5_7: - macos: - xcode: 13.4.1 - steps: - - checkout - - run: xcodebuild -scheme FloatingPanel -workspace FloatingPanel.xcworkspace SWIFT_VERSION=5.7 clean build - - build-swiftpm_ios15_7: - macos: - xcode: 13.4.1 - steps: - - checkout - - run: swift build -Xswiftc "-sdk" -Xswiftc "`xcrun --sdk iphonesimulator --show-sdk-path`" -Xswiftc "-target" -Xswiftc "x86_64-apple-ios15.7-simulator" - - run: swift build -Xswiftc "-sdk" -Xswiftc "`xcrun --sdk iphonesimulator --show-sdk-path`" -Xswiftc "-target" -Xswiftc "arm64-apple-ios15.7-simulator" - - test-ios15_5-iPhone_13_Pro: - macos: - xcode: 13.4.1 - steps: - - checkout - - run: xcodebuild clean test -scheme FloatingPanel -workspace FloatingPanel.xcworkspace -destination 'platform=iOS Simulator,OS=15.5,name=iPhone 13 Pro' - test-ios14_5-iPhone_12_Pro: - macos: - xcode: 13.4.1 - steps: - - checkout - - run: xcodebuild clean test -scheme FloatingPanel -workspace FloatingPanel.xcworkspace -destination 'platform=iOS Simulator,OS=14.5,name=iPhone 12 Pro' - - -workflows: - test: - jobs: - - build-swift_5_7: - name: build (5.7, 13.4.1) - - build-swiftpm_ios15_7: - name: swiftpm ({x86_64,arm64}-apple-ios15.5-simulator, 13.4.1) - - test-ios14_5-iPhone_12_Pro: - name: test (15.5, 13.4.1, iPhone 12 Pro) - - test-ios15_5-iPhone_13_Pro: - name: test (14.5, 13.4.1, iPhone 13 Pro) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bcefcde6..97dc9f036 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,13 @@ name: ci -on: +on: push: - branches: + branches: - master - - next pull_request: - branches: - - '*' + branches: + - "*" + workflow_dispatch: jobs: build: @@ -19,17 +19,11 @@ jobs: matrix: include: - swift: "5" - xcode: "16.2" + xcode: "16.4" runs-on: macos-15 - swift: "5.10" xcode: "15.4" runs-on: macos-14 - - swift: "5.9" - xcode: "15.2" - runs-on: macos-13 - - swift: "5.8" - xcode: "14.3.1" - runs-on: macos-13 steps: - uses: actions/checkout@v4 - name: Building in Swift ${{ matrix.swift }} @@ -43,36 +37,36 @@ jobs: fail-fast: false matrix: include: - - os: "18.2" - xcode: "16.2" + - os: "26.0.1" + xcode: "26.0.1" sim: "iPhone 16 Pro" - parallel: NO # Stop random test job failures + parallel: NO # Stop random test job failures + runs-on: macos-15 + - os: "18.5" + xcode: "16.4" + sim: "iPhone 16 Pro" + parallel: NO # Stop random test job failures runs-on: macos-15 - os: "17.5" xcode: "15.4" sim: "iPhone 15 Pro" - parallel: NO # Stop random test job failures + parallel: NO # Stop random test job failures runs-on: macos-14 - - os: "16.4" - xcode: "14.3.1" - sim: "iPhone 14 Pro" - parallel: NO # Stop random test job failures - runs-on: macos-13 steps: - - uses: actions/checkout@v4 - - name: Testing in iOS ${{ matrix.os }} - run: | - xcodebuild clean test \ - -workspace FloatingPanel.xcworkspace \ - -scheme FloatingPanel \ - -destination 'platform=iOS Simulator,OS=${{ matrix.os }},name=${{ matrix.sim }}' \ - -parallel-testing-enabled '${{ matrix.parallel }}' + - uses: actions/checkout@v4 + - name: Testing in iOS ${{ matrix.os }} + run: | + xcodebuild clean test \ + -workspace FloatingPanel.xcworkspace \ + -scheme FloatingPanel \ + -destination 'platform=iOS Simulator,OS=${{ matrix.os }},name=${{ matrix.sim }}' \ + -parallel-testing-enabled '${{ matrix.parallel }}' timeout-minutes: 20 example: runs-on: macos-15 env: - DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.0.1.app/Contents/Developer strategy: fail-fast: false matrix: @@ -81,82 +75,73 @@ jobs: - example: "Maps-SwiftUI" - example: "Stocks" - example: "Samples" + - example: "SamplesObjC" + - example: "SamplesSwiftUI" steps: - - uses: actions/checkout@v4 - - name: Building ${{ matrix.example }} - # Need to use iphonesimulator18.1 because randomly 18.2 isn't available. - run: | - xcodebuild clean build \ - -workspace FloatingPanel.xcworkspace \ - -scheme ${{ matrix.example }} \ - -sdk iphonesimulator + - uses: actions/checkout@v4 + - name: Building ${{ matrix.example }} + run: | + xcodebuild clean build \ + -workspace FloatingPanel.xcworkspace \ + -scheme ${{ matrix.example }} \ + -sdk iphonesimulator swiftpm: - runs-on: macos-15 + runs-on: ${{ matrix.runs-on }} env: DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer strategy: fail-fast: false matrix: - xcode: ["16.2", "15.4"] + xcode: ["26.0.1", "16.4", "15.4"] platform: [iphoneos, iphonesimulator] arch: [x86_64, arm64] exclude: - platform: iphoneos arch: x86_64 include: - # 18.2 + # 26.0.1 - platform: iphoneos - xcode: "16.2" - sys: "ios18.2" + xcode: "26.0.1" + sys: "ios26.0" + runs-on: macos-15 - platform: iphonesimulator - xcode: "16.2" - sys: "ios18.2-simulator" + xcode: "26.0.1" + sys: "ios26.0-simulator" + runs-on: macos-15 + # 18.5 + - platform: iphoneos + xcode: "16.4" + sys: "ios18.5" + runs-on: macos-15 + - platform: iphonesimulator + xcode: "16.4" + sys: "ios18.5-simulator" + runs-on: macos-15 # 17.2 - platform: iphoneos xcode: "15.4" sys: "ios17.2" + runs-on: macos-14 - platform: iphonesimulator xcode: "15.4" sys: "ios17.2-simulator" + runs-on: macos-14 steps: - - uses: actions/checkout@v4 - - name: "Swift Package Manager build" - run: | - xcrun swift build \ - --sdk "$(xcrun --sdk ${{ matrix.platform }} --show-sdk-path)" \ - -Xswiftc "-target" -Xswiftc "${{ matrix.arch }}-apple-${{ matrix.sys }}" - - swiftpm_old: - runs-on: ${{ matrix.runs-on }} - env: - DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer - strategy: - fail-fast: false - matrix: - include: - # 16.4 - - target: "x86_64-apple-ios16.4-simulator" - xcode: "14.3.1" - runs-on: macos-13 - - target: "arm64-apple-ios16.4-simulator" - xcode: "14.3.1" - runs-on: macos-13 - steps: - - uses: actions/checkout@v4 - - name: "Swift Package Manager build" - run: | - swift build \ - -Xswiftc "-sdk" -Xswiftc "`xcrun --sdk iphonesimulator --show-sdk-path`" \ - -Xswiftc "-target" -Xswiftc "${{ matrix.target }}" + - uses: actions/checkout@v4 + - name: "Swift Package Manager build" + run: | + xcrun swift build \ + --sdk "$(xcrun --sdk ${{ matrix.platform }} --show-sdk-path)" \ + -Xswiftc "-target" -Xswiftc "${{ matrix.arch }}-apple-${{ matrix.sys }}" cocoapods: runs-on: macos-15 env: - DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.0.1.app/Contents/Developer steps: - - uses: actions/checkout@v4 - - name: "CocoaPods: pod lib lint" - run: pod lib lint --allow-warnings --verbose - - name: "CocoaPods: pod spec lint" - run: pod spec lint --allow-warnings --verbose + - uses: actions/checkout@v4 + - name: "CocoaPods: pod lib lint" + run: pod lib lint --allow-warnings --verbose + - name: "CocoaPods: pod spec lint" + run: pod spec lint --allow-warnings --verbose diff --git a/.gitignore b/.gitignore index e69908207..04f469af0 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ DerivedData/ !default.perspectivev3 xcuserdata/ IDEWorkspaceChecks.plis +*.xcodeproj/project.xcworkspace ## Other *.moved-aside diff --git a/.swift-format b/.swift-format new file mode 100644 index 000000000..adf28df56 --- /dev/null +++ b/.swift-format @@ -0,0 +1,79 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "indentConditionalCompilationBlocks" : false, + "indentSwitchCaseLabels" : false, + "indentation" : { + "spaces" : 4 + }, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineBreakBetweenDeclarationAttributes" : false, + "lineLength" : 120, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "reflowMultilineStringLiterals" : { + "never" : { + + } + }, + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "AvoidRetroactiveConformances" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyLinesOpeningClosingBraces" : false, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : false + }, + "spacesAroundRangeFormationOperators" : false, + "spacesBeforeEndOfLineComments" : 2, + "tabWidth" : 8, + "version" : 1 +} diff --git a/BuildTools/.gitignore b/BuildTools/.gitignore new file mode 100644 index 000000000..578674aa5 --- /dev/null +++ b/BuildTools/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc + diff --git a/BuildTools/Package.swift b/BuildTools/Package.swift new file mode 100644 index 000000000..07a67c3e8 --- /dev/null +++ b/BuildTools/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "build-tools", + products: [ + .plugin( + name: "swift-format-plugin", + targets: ["swift-format-plugin"] + ) + ], + targets: [ + .plugin( + name: "swift-format-plugin", + capability: .buildTool() + ) + ] +) diff --git a/BuildTools/Package@swift-5.9.swift b/BuildTools/Package@swift-5.9.swift new file mode 100644 index 000000000..1efcbce5d --- /dev/null +++ b/BuildTools/Package@swift-5.9.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.9 +// This file is used when the 'built-tools' package is built by Xcode 15 or earlier. + +import PackageDescription + +let package = Package( + name: "build-tools", + products: [ + .plugin( + name: "swift-format-plugin", + targets: ["swift-format-plugin"] + ) + ], + targets: [ + .plugin( + name: "swift-format-plugin", + capability: .buildTool() + ) + ] +) diff --git a/BuildTools/Plugins/swift-format-plugin.swift b/BuildTools/Plugins/swift-format-plugin.swift new file mode 100644 index 000000000..18d0eed7d --- /dev/null +++ b/BuildTools/Plugins/swift-format-plugin.swift @@ -0,0 +1,60 @@ +import Foundation +import PackagePlugin + +@main +struct SwiftFormatBuildToolPlugin: BuildToolPlugin { + func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { + // Currently the build tool plugin is not supported. + return [] + } +} + +#if canImport(XcodeProjectPlugin) +import XcodeProjectPlugin + +/// Formats Swift source files using the `swift format` command and a root configuration file during Xcode builds. +extension SwiftFormatBuildToolPlugin: XcodeBuildToolPlugin { + // Entry point for creating build commands for targets in Xcode projects. + func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { + #if swift(>=6.0) + let swift = try context.tool(named: "swift").url + let xcodeProjectDirectoryURL = context.xcodeProject.directoryURL + // Find the code generator tool to run (replace this with the actual one). + print("SwiftFormatBuildToolPlugin -> \(xcodeProjectDirectoryURL.filePath)") + let configFile = xcodeProjectDirectoryURL.appending(path: ".swift-format").filePath + let targetFiles = [ + // Currently check only 'SwiftUI' source code in 'Sources' dir. + xcodeProjectDirectoryURL.appending(path: "Sources/SwiftUI"), + // xcodeProjectDirectoryURL.appending(path: "Sources"), + // xcodeProjectDirectoryURL.appending(path: "Tests"), + xcodeProjectDirectoryURL.appending(path: "BuildTools"), + // Currently check only 'SamplesSwiftUI' source code in 'Examples' dir. + xcodeProjectDirectoryURL.appending(path: "Examples/SamplesSwiftUI"), + ].map { $0.filePath } + return [ + .buildCommand( + displayName: "Run swift format(xcode)", + executable: swift, + arguments: [ + "format", + "lint", + "--configuration", + configFile, + "-r", + ] + targetFiles, + inputFiles: [], + outputFiles: [] + ) + ] + #else + // Skip running `swift format`; this subcommand ships with the Swift 6 compiler. + return [] + #endif + } +} + +extension URL { + /// Returns a non–percent-encoded path for use with components containing non-ASCII characters. + var filePath: String { path(percentEncoded: false) } +} +#endif diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..0b4c9741a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,182 @@ +# ChangeLog + +## [3.2.1](https://github.com/scenee/FloatingPanel/releases/tag/3.2.1) + +### Fixed + +- Fixed scroll offset reset due to floating-point precision errors (#677). +- Fixed view updates during scroll tracking (#679). + +## [3.2.0](https://github.com/scenee/FloatingPanel/releases/tag/3.2.0) + +### Added + +- Added support for dynamic content updates in SwiftUI (#675). + +### Fixed + +- Fixed shadow rendering issues on iOS 26. +- Fixed animation glitches in example apps on iOS 26. + +### Changed + +- Updated CI to use Xcode 26.0.1 for all builds. +- Improved SamplesSwiftUI example app. + +## [3.1.0](https://github.com/scenee/FloatingPanel/releases/tag/3.1.0) + +### Added + +- Added support for configuring `UICornerConfiguration` when running on iOS 26 so surfaces can adopt the latest corner styles (#664). +- Expanded Core test coverage with new cases for scroll offset resets and `statePublisher` delivery to guard against regressions. + +### Changed + +- **Restored compatibility with iOS 12** by lowering the deployment target across the project, Swift Package manifest, and CocoaPods spec, and by making Combine usage conditional (#674). + +## [3.0.1](https://github.com/scenee/FloatingPanel/releases/tag/3.0.1) + +### Changed + +- Refined the DocC documentation landing page for the new SwiftUI APIs. + +### Fixed + +- Corrected the CocoaPods spec so the SwiftUI sources are packaged properly. + +## [3.0.0](https://github.com/scenee/FloatingPanel/releases/tag/3.0.0) + +### Breaking Changes + +- The minimum deployment target is now **iOS 13.0**. +- Dropped support for building with **Xcode 13.4.1**. + +### Added + +- Introduced new SwiftUI APIs. +- Added `Documentation/FloatingPanel SwiftUI API Guide.md`. +- Added `Documentation/FloatingPanel API Guide.md`, migrating from `README.md`. +- Added `Examples/SamplesSwiftUI` example app. +- Added `FloatingPanelControllerDelegate/floatingPanel(_:animatorForMovingTo:)`. +- Added partial `swift-format` support via the BuildTools plugin package. + **Limitation:** Formatting currently applies only to the source code for the + new SwiftUI API and the `SamplesSwiftUI` example app. +- Enabled README preview mode in Xcode via `.xcodesamplecode.plist`. + +### Changed + +- Updated `README.md` to cover the new SwiftUI APIs. +- Moved UIKit-specific details to `Documentation/FloatingPanel API Guide.md`. +- Updated DocC documentation for the new SwiftUI APIs. +- Moved the `assets` folder. + +## [2.8.8](https://github.com/scenee/FloatingPanel/releases/tag/2.8.8) + +### Bugfixes + +- Allowed slight deviation when checking for anchor position. +- Addressed #661 issue since v2.8.0 (#662) + +## [2.8.7](https://github.com/scenee/FloatingPanel/releases/tag/2.8.7) + +:warning: [NOTICE] This release contains a regression. Please use v2.8.8 instead. + +### Bugfixes + +- Disallow interrupting the panel interaction while bouncing over the most + expanded state (#652) +- Reset initialScrollOffset after the attracting animation ends (#659) + +## [2.8.6](https://github.com/scenee/FloatingPanel/releases/tag/2.8.6) + +### Bugfixes + +- Fix doc comment errors (#643) + +## [2.8.5](https://github.com/scenee/FloatingPanel/releases/tag/2.8.5) + +### Bugfixes + +- Replaced fatal errors in transitionDuration delegate methods (#642) + +## [2.8.4](https://github.com/scenee/FloatingPanel/releases/tag/2.8.4) + +### Bugfixes + +- Fixed an inappropriate condition to determine scrolling content (#633) + +## [2.8.3](https://github.com/scenee/FloatingPanel/releases/tag/2.8.3) + +### Bugfixes + +- Fix the scroll tracking of WKWebView on iOS 17.4 (#630) +- Fix a broken panel layout with a compositional collection view (#634) +- Fix a compilation error in Xcode 16 by @WillBishop (#636) + +## [2.8.2](https://github.com/scenee/FloatingPanel/releases/tag/2.8.2) + +### New features + +- Enabled to define and use a subclass object of BackdropView (#617) + +### Improvements + +- Fixed the scroll locking behavior by @futuretap (#615) +- Supported Xcode 15.2 on the GitHub Actions (#619) + +### Bugfixes + +- Added a possible fix for #586 +- Fixed a bug that state was not changed property after v2.8.1 + +## [2.8.1](https://github.com/scenee/FloatingPanel/releases/tag/2.8.1) + +- Fixed an invalid behavior after switching to a new layout object (#611) + +## [2.8.0](https://github.com/scenee/FloatingPanel/releases/tag/2.8.0) + +### Breaking changes + +- The minimum deployment target of this library became iOS 11.0 on this release. + +### New features + +- Added the new delegate method, `floatingPanel(_:shouldAllowToScroll:in:)`. + +### Improvements + +- Enabled content scrolling in non-expanded states (#455) + +### Bugfixes + +- Fixed CGFloat.rounded(by:) for a floating point error +- Fixed scroll offset reset when moving in grabber area +- Fixed a panel not moving when picked up in certain area +- Fixed errors of offset value from a state position + +## [2.7.0](https://github.com/scenee/FloatingPanel/releases/tag/2.7.0) + +### Breaking changes + +- Calls the `floatingPanelDidMove` delegate method at the end of the move + interaction. +- Calls the `floatingPanelDidEndDragging` delegate method after + `FloatingPanelController.state` changes when `willAttract` is `false`. +- Sets `isAttracting` to `true` even when moving between states by + `FloatingPanelController.move(to:animated:completion)` except for moves from + or to `.hidden`. +- Do not reset the scroll offset of its tracking scroll view when a user moves a + panel outside its scroll view or on the navigation bar above it. + +## Improvements + +- Added `FloatingPanelPanGestureRecognizer.delegateOrigin` to allow to access + the default delegate implementations (It's useful when using `delegateProxy`). + +## Bugfixes + +- Retains scroll view position while moving between states (#587) +- Fixed invalid scroll offsets after moving between states +- Calls `floatingPanelWillRemove` delegate method when a panel is removed from a + window + diff --git a/Documentation/FloatingPanel 2.0 Migration Guide.md b/Documentation/FloatingPanel 2.0 Migration Guide.md index 7084efa32..cb650363f 100644 --- a/Documentation/FloatingPanel 2.0 Migration Guide.md +++ b/Documentation/FloatingPanel 2.0 Migration Guide.md @@ -19,7 +19,7 @@ This guide is provided in order to ease the transition of existing applications * __Flexible and explicit layout customization__ * `FloatingPanelLayout` is redesigned. There is no implicit rules to lay out a panel anymore. * __New spring animation without UIViewPropertyAnimator__ - * The new spring animation uses [Numeric springing](http://allenchou.net/2015/04/game-math-precise-control-over-numeric-springing/) which is a very powerful tool for procedural animation. Therefore a library consumer is easy to modify a panel behavior by 2 paramters of the deceleration rate and response time. + * The new spring animation uses [Numeric springing](http://allenchou.net/2015/04/game-math-precise-control-over-numeric-springing/) which is a very powerful tool for procedural animation. Therefore a library consumer is easy to modify a panel behavior by 2 parameters of the deceleration rate and response time. * __Handle the panel position anytime__ * `floatingPanelDidMove(_:)` delegate method is also called while a panel is moving. The method behavior becomes same as `scrollViewDidScroll(_:)` in `UIScrollViewDelegate`. And in the method a library consumer is able to change a panel location. * __Update the removal interaction's invocation__ diff --git a/Documentation/FloatingPanel API Guide.md b/Documentation/FloatingPanel API Guide.md new file mode 100644 index 000000000..1658d2ace --- /dev/null +++ b/Documentation/FloatingPanel API Guide.md @@ -0,0 +1,603 @@ +# Floating Panel API Guide + +## Table of Contents + +
+Click here + +- [Table of Contents](#table-of-contents) +- [View hierarchy](#view-hierarchy) +- [Usage](#usage) + - [Show/Hiding a floating panel in a view with your view hierarchy](#showhiding-a-floating-panel-in-a-view-with-your-view-hierarchy) + - [Scaling the content view when the surface position changes](#scaling-the-content-view-when-the-surface-position-changes) + - [Customizing the layout with `FloatingPanelLayout` protocol](#customizing-the-layout-with-floatingpanellayout-protocol) + - [Changing the initial layout](#changing-the-initial-layout) + - [Updating your panel layout](#updating-your-panel-layout) + - [Supporting your landscape layout](#supporting-your-landscape-layout) + - [Using the intrinsic size of a content in your panel layout](#using-the-intrinsic-size-of-a-content-in-your-panel-layout) + - [Specifying an anchor for each state by an inset of the `FloatingPanelController.view` frame](#specifying-an-anchor-for-each-state-by-an-inset-of-the-floatingpanelcontrollerview-frame) + - [Changing the backdrop alpha](#changing-the-backdrop-alpha) + - [Using custom panel states](#using-custom-panel-states) + - [Customizing the behavior with `FloatingPanelBehavior` protocol](#customizing-the-behavior-with-floatingpanelbehavior-protocol) + - [Modifying your floating panel's interaction](#modifying-your-floating-panels-interaction) + - [Activating the rubber-band effect on panel edges](#activating-the-rubber-band-effect-on-panel-edges) + - [Managing the projection of a pan gesture momentum](#managing-the-projection-of-a-pan-gesture-momentum) + - [Specifying the panel move's boundary](#specifying-the-panel-moves-boundary) + - [Customizing the surface design](#customizing-the-surface-design) + - [Modifying your surface appearance](#modifying-your-surface-appearance) + - [Use a custom grabber handle](#use-a-custom-grabber-handle) + - [Customizing layout of the grabber handle](#customizing-layout-of-the-grabber-handle) + - [Customizing content padding from surface edges](#customizing-content-padding-from-surface-edges) + - [Customizing margins of the surface edges](#customizing-margins-of-the-surface-edges) + - [Customizing gestures](#customizing-gestures) + - [Suppressing the panel interaction](#suppressing-the-panel-interaction) + - [Adding tap gestures to the surface view](#adding-tap-gestures-to-the-surface-view) + - [Interrupting the delegate methods of `FloatingPanelController.panGestureRecognizer`](#interrupting-the-delegate-methods-of-floatingpanelcontrollerpangesturerecognizer) + - [Creating an additional floating panel for a detail](#creating-an-additional-floating-panel-for-a-detail) + - [Moving a position with an animation](#moving-a-position-with-an-animation) + - [Working your contents together with a floating panel behavior](#working-your-contents-together-with-a-floating-panel-behavior) + - [Enabling the tap-to-dismiss action of the backdrop view](#enabling-the-tap-to-dismiss-action-of-the-backdrop-view) + - [Allowing to scroll content of the tracking scroll view in addition to the most expanded state](#allowing-to-scroll-content-of-the-tracking-scroll-view-in-addition-to-the-most-expanded-state) +- [Notes](#notes) + - ['Show' or 'Show Detail' Segues from `FloatingPanelController`'s content view controller](#show-or-show-detail-segues-from-floatingpanelcontrollers-content-view-controller) + - [UISearchController issue](#uisearchcontroller-issue) + +
+ +## View hierarchy + +`FloatingPanelController` manages the views as the following view hierarchy. + +```text +FloatingPanelController.view (FloatingPanelPassThroughView) + ├─ .backdropView (FloatingPanelBackdropView) + └─ .surfaceView (FloatingPanelSurfaceView) + ├─ .containerView (UIView) + │ └─ .contentView (FloatingPanelController.contentViewController.view) + └─ .grabber (FloatingPanelGrabberView) +``` + +## Usage + +### Show/Hiding a floating panel in a view with your view hierarchy + +If you need more control over showing and hiding the floating panel, you can forgo the `addPanel` and `removePanelFromParent` methods. These methods are a convenience wrapper for **FloatingPanel**'s `show` and `hide` methods along with some required setup. + +There are two ways to work with the `FloatingPanelController`: + +1. Add it to the hierarchy once and then call `show` and `hide` methods to make it appear/disappear. +2. Add it to the hierarchy when needed and remove afterwards. + +The following example shows how to add the controller to your `UIViewController` and how to remove it. Make sure that you never add the same `FloatingPanelController` to the hierarchy before removing it. + +**NOTE**: `self.` prefix is not required, nor recommended. It's used here to make it clearer where do the functions used come from. `self` is an instance of a custom UIViewController in your code. + +```swift +// Add the floating panel view to the controller's view on top of other views. +self.view.addSubview(fpc.view) + +// REQUIRED. It makes the floating panel view have the same size as the controller's view. +fpc.view.frame = self.view.bounds + +// In addition, Auto Layout constraints are highly recommended. +// Constraint the fpc.view to all four edges of your controller's view. +// It makes the layout more robust on trait collection change. +fpc.view.translatesAutoresizingMaskIntoConstraints = false +NSLayoutConstraint.activate([ + fpc.view.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 0.0), + fpc.view.leftAnchor.constraint(equalTo: self.view.leftAnchor, constant: 0.0), + fpc.view.rightAnchor.constraint(equalTo: self.view.rightAnchor, constant: 0.0), + fpc.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0.0), +]) + +// Add the floating panel controller to the controller hierarchy. +self.addChild(fpc) + +// Show the floating panel at the initial position defined in your `FloatingPanelLayout` object. +fpc.show(animated: true) { + // Inform the floating panel controller that the transition to the controller hierarchy has completed. + fpc.didMove(toParent: self) +} +``` + +After you add the `FloatingPanelController` as seen above, you can call `fpc.show(animated: true) { }` to show the panel and `fpc.hide(animated: true) { }` to hide it. + +To remove the `FloatingPanelController` from the hierarchy, follow the example below. + +```swift +// Inform the panel controller that it will be removed from the hierarchy. +fpc.willMove(toParent: nil) + +// Hide the floating panel. +fpc.hide(animated: true) { + // Remove the floating panel view from your controller's view. + fpc.view.removeFromSuperview() + // Remove the floating panel controller from the controller hierarchy. + fpc.removeFromParent() +} +``` + +### Scaling the content view when the surface position changes + +Specify the `contentMode` to `.fitToBounds` if the surface height fits the bounds of `FloatingPanelController.view` when the surface position changes + +```swift +fpc.contentMode = .fitToBounds +``` + +Otherwise, `FloatingPanelController` fixes the content by the height of the top most position. + +> [!NOTE] +> In `.fitToBounds` mode, the surface height changes as following a user interaction so that you have a responsibility to configure Auto Layout constrains not to break the layout of a content view by the elastic surface height. + +### Customizing the layout with `FloatingPanelLayout` protocol + +#### Changing the initial layout + +```swift +class ViewController: UIViewController, FloatingPanelControllerDelegate { + ... { + fpc = FloatingPanelController(delegate: self) + fpc.layout = MyFloatingPanelLayout() + } +} + +class MyFloatingPanelLayout: FloatingPanelLayout { + let position: FloatingPanelPosition = .bottom + let initialState: FloatingPanelState = .tip + let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [ + .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea), + .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .safeArea), + .tip: FloatingPanelLayoutAnchor(absoluteInset: 44.0, edge: .bottom, referenceGuide: .safeArea), + ] +} +``` + +### Updating your panel layout + +There are 2 ways to update the panel layout. + +1. Manually set `FloatingPanelController.layout` to the new layout object directly. + +```swift +fpc.layout = MyPanelLayout() +fpc.invalidateLayout() // If needed +``` + +Note: If you already set the `delegate` property of your `FloatingPanelController` instance, `invalidateLayout()` overrides the layout object of `FloatingPanelController` with one returned by the delegate object. + +2. Returns an appropriate layout object in one of 2 `floatingPanel(_:layoutFor:)` delegates. + +```swift +class ViewController: UIViewController, FloatingPanelControllerDelegate { + ... + func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout { + return MyFloatingPanelLayout() + } + + // OR + func floatingPanel(_ vc: FloatingPanelController, layoutFor size: CGSize) -> FloatingPanelLayout { + return MyFloatingPanelLayout() + } +} +``` + +#### Supporting your landscape layout + +```swift +class ViewController: UIViewController, FloatingPanelControllerDelegate { + ... + func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout { + return (newCollection.verticalSizeClass == .compact) ? LandscapePanelLayout() : FloatingPanelBottomLayout() + } +} + +class LandscapePanelLayout: FloatingPanelLayout { + let position: FloatingPanelPosition = .bottom + let initialState: FloatingPanelState = .tip + let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [ + .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea), + .tip: FloatingPanelLayoutAnchor(absoluteInset: 69.0, edge: .bottom, referenceGuide: .safeArea), + ] + + func prepareLayout(surfaceView: UIView, in view: UIView) -> [NSLayoutConstraint] { + return [ + surfaceView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor, constant: 8.0), + surfaceView.widthAnchor.constraint(equalToConstant: 291), + ] + } +} +``` + +#### Using the intrinsic size of a content in your panel layout + +1. Lay out your content View with the intrinsic height size. For example, see "Detail View Controller scene"/"Intrinsic View Controller scene" of [Main.storyboard](Examples/Samples/Sources/Base.lproj/Main.storyboard). The 'Stack View.bottom' constraint determines the intrinsic height. +2. Specify layout anchors using `FloatingPanelIntrinsicLayoutAnchor`. + +```swift +class IntrinsicPanelLayout: FloatingPanelLayout { + let position: FloatingPanelPosition = .bottom + let initialState: FloatingPanelState = .full + let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [ + .full: FloatingPanelIntrinsicLayoutAnchor(absoluteOffset: 0, referenceGuide: .safeArea), + .half: FloatingPanelIntrinsicLayoutAnchor(fractionalOffset: 0.5, referenceGuide: .safeArea), + ] + ... +} +``` + +> [!WARNING] +> `FloatingPanelIntrinsicLayout` is deprecated on v1. + +#### Specifying an anchor for each state by an inset of the `FloatingPanelController.view` frame + +Use `.superview` reference guide in your anchors. + +```swift +class MyFullScreenLayout: FloatingPanelLayout { + ... + let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [ + .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .superview), + .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .superview), + .tip: FloatingPanelLayoutAnchor(absoluteInset: 44.0, edge: .bottom, referenceGuide: .superview), + ] +} +``` + +> [!WARNING] +> `FloatingPanelFullScreenLayout` is deprecated on v1. + +#### Changing the backdrop alpha + +You can change the backdrop alpha by `FloatingPanelLayout.backdropAlpha(for:)` for each state(`.full`, `.half` and `.tip`). + +For instance, if a panel seems like the backdrop view isn't there on `.half` state, it's time to implement the backdropAlpha API and return a value for the state as below. + +```swift +class MyPanelLayout: FloatingPanelLayout { + func backdropAlpha(for state: FloatingPanelState) -> CGFloat { + switch state { + case .full, .half: return 0.3 + default: return 0.0 + } + } +} +``` + +#### Using custom panel states + +You're able to define custom panel states and use them as the following example. + +```swift +extension FloatingPanelState { + static let lastQuart: FloatingPanelState = FloatingPanelState(rawValue: "lastQuart", order: 750) + static let firstQuart: FloatingPanelState = FloatingPanelState(rawValue: "firstQuart", order: 250) +} + +class FloatingPanelLayoutWithCustomState: FloatingPanelBottomLayout { + override var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] { + return [ + .full: FloatingPanelLayoutAnchor(absoluteInset: 0.0, edge: .top, referenceGuide: .safeArea), + .lastQuart: FloatingPanelLayoutAnchor(fractionalInset: 0.75, edge: .bottom, referenceGuide: .safeArea), + .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .safeArea), + .firstQuart: FloatingPanelLayoutAnchor(fractionalInset: 0.25, edge: .bottom, referenceGuide: .safeArea), + .tip: FloatingPanelLayoutAnchor(absoluteInset: 20.0, edge: .bottom, referenceGuide: .safeArea), + ] + } +} +``` + +### Customizing the behavior with `FloatingPanelBehavior` protocol + +#### Modifying your floating panel's interaction + +```swift +class ViewController: UIViewController, FloatingPanelControllerDelegate { + ... + func viewDidLoad() { + ... + fpc.behavior = CustomPanelBehavior() + } +} + +class CustomPanelBehavior: FloatingPanelBehavior { + let springDecelerationRate = UIScrollView.DecelerationRate.fast.rawValue + 0.02 + let springResponseTime = 0.4 + func shouldProjectMomentum(_ fpc: FloatingPanelController, to proposedState: FloatingPanelState) -> Bool { + return true + } +} +``` + +> [!WARNING] +> `floatingPanel(_ vc:behaviorFor:)` is deprecated on v1. + +#### Activating the rubber-band effect on panel edges + +```swift +class MyPanelBehavior: FloatingPanelBehavior { + ... + func allowsRubberBanding(for edge: UIRectEdge) -> Bool { + return true + } +} +``` + +#### Managing the projection of a pan gesture momentum + +This allows full projectional panel behavior. For example, a user can swipe up a panel from tip to full nearby the tip position. + +```swift +class MyPanelBehavior: FloatingPanelBehavior { + ... + func shouldProjectMomentum(_ fpc: FloatingPanelController, to proposedState: FloatingPanelState) -> Bool { + return true + } +} +``` + +### Specifying the panel move's boundary + +`FloatingPanelController.surfaceLocation` in `floatingPanelDidMove(_:)` delegate method behaves like `UIScrollView.contentOffset` in `scrollViewDidScroll(_:)`. +As a result, you can specify the boundary of a panel move as below. + +```swift +func floatingPanelDidMove(_ vc: FloatingPanelController) { + if vc.isAttracting == false { + let loc = vc.surfaceLocation + let minY = vc.surfaceLocation(for: .full).y - 6.0 + let maxY = vc.surfaceLocation(for: .tip).y + 6.0 + vc.surfaceLocation = CGPoint(x: loc.x, y: min(max(loc.y, minY), maxY)) + } +} +``` + +> [!WARNING] +> `{top,bottom}InteractionBuffer` property is removed from `FloatingPanelLayout` since v2. + +### Customizing the surface design + +#### Modifying your surface appearance + +```swift +// Create a new appearance. +let appearance = SurfaceAppearance() + +// Define shadows +let shadow = SurfaceAppearance.Shadow() +shadow.color = UIColor.black +shadow.offset = CGSize(width: 0, height: 16) +shadow.radius = 16 +shadow.spread = 8 +appearance.shadows = [shadow] + +// Define corner radius and background color +appearance.cornerRadius = 8.0 +appearance.backgroundColor = .clear + +// Set the new appearance +fpc.surfaceView.appearance = appearance +```` + +#### Use a custom grabber handle + +```swift +let myGrabberHandleView = MyGrabberHandleView() +fpc.surfaceView.grabberHandle.isHidden = true +fpc.surfaceView.addSubview(myGrabberHandleView) +``` + +#### Customizing layout of the grabber handle + +```swift +fpc.surfaceView.grabberHandlePadding = 10.0 +fpc.surfaceView.grabberHandleSize = .init(width: 44.0, height: 12.0) +``` + +> [!NOTE] +> `grabberHandleSize` width and height are reversed in the left/right position. + +#### Customizing content padding from surface edges + +```swift +fpc.surfaceView.contentPadding = .init(top: 20, left: 20, bottom: 20, right: 20) +``` + +#### Customizing margins of the surface edges + +```swift +fpc.surfaceView.containerMargins = .init(top: 20.0, left: 16.0, bottom: 16.0, right: 16.0) +``` + +The feature can be used for these 2 kind panels + +- Facebook/Slack-like panel whose surface top edge is separated from the grabber handle. +- iOS native panel to display AirPods information, for example. + +### Customizing gestures + +#### Suppressing the panel interaction + +You can disable the pan gesture recognizer directly + +```swift +fpc.panGestureRecognizer.isEnabled = false +``` + +Or use this `FloatingPanelControllerDelegate` method. + +```swift +func floatingPanelShouldBeginDragging(_ vc: FloatingPanelController) -> Bool { + return aCondition ? false : true +} +``` + +#### Adding tap gestures to the surface view + +```swift +override func viewDidLoad() { + ... + let surfaceTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleSurface(tapGesture:))) + fpc.surfaceView.addGestureRecognizer(surfaceTapGesture) + surfaceTapGesture.isEnabled = (fpc.position == .tip) +} + +// Enable `surfaceTapGesture` only at `tip` state +func floatingPanelDidChangeState(_ vc: FloatingPanelController) { + surfaceTapGesture.isEnabled = (vc.position == .tip) +} +``` + +#### Interrupting the delegate methods of `FloatingPanelController.panGestureRecognizer` + +If you are set `FloatingPanelController.panGestureRecognizer.delegateProxy` to an object adopting `UIGestureRecognizerDelegate`, it overrides delegate methods of the pan gesture recognizer. + +```swift +class MyGestureRecognizerDelegate: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return false + } +} + +class ViewController: UIViewController { + let myGestureDelegate = MyGestureRecognizerDelegate() + + func setUpFpc() { + .... + fpc.panGestureRecognizer.delegateProxy = myGestureDelegate + } +``` + +### Creating an additional floating panel for a detail + +```swift +override func viewDidLoad() { + // Setup Search panel + self.searchPanelVC = FloatingPanelController() + + let searchVC = SearchViewController() + self.searchPanelVC.set(contentViewController: searchVC) + self.searchPanelVC.track(scrollView: contentVC.tableView) + + self.searchPanelVC.addPanel(toParent: self) + + // Setup Detail panel + self.detailPanelVC = FloatingPanelController() + + let contentVC = ContentViewController() + self.detailPanelVC.set(contentViewController: contentVC) + self.detailPanelVC.track(scrollView: contentVC.scrollView) + + self.detailPanelVC.addPanel(toParent: self) +} +``` + +### Moving a position with an animation + +In the following example, I move a floating panel to full or half position while opening or closing a search bar like Apple Maps. + +```swift +func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + ... + fpc.move(to: .half, animated: true) +} + +func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + ... + fpc.move(to: .full, animated: true) +} +``` + +You can also use a view animation to move a panel. + +```swift +UIView.animate(withDuration: 0.25) { + self.fpc.move(to: .half, animated: false) +} +``` + +### Working your contents together with a floating panel behavior + +```swift +class ViewController: UIViewController, FloatingPanelControllerDelegate { + ... + func floatingPanelWillBeginDragging(_ vc: FloatingPanelController) { + if vc.position == .full { + searchVC.searchBar.showsCancelButton = false + searchVC.searchBar.resignFirstResponder() + } + } + + func floatingPanelWillEndDragging(_ vc: FloatingPanelController, withVelocity velocity: CGPoint, targetState: UnsafeMutablePointer) { + if targetState.pointee != .full { + searchVC.hideHeader() + } + } +} +``` + +### Enabling the tap-to-dismiss action of the backdrop view + +The tap-to-dismiss action is disabled by default. So it needs to be enabled as below. + +```swift +fpc.backdropView.dismissalTapGestureRecognizer.isEnabled = true +``` + +### Allowing to scroll content of the tracking scroll view in addition to the most expanded state + +Just define conditions to allow content scrolling in `floatingPanel(:_:shouldAllowToScroll:in)` delegate method. If the returned value is true, the scroll content scrolls when its scroll position is not at the top of the content. + +```swift +class MyViewController: FloatingPanelControllerDelegate { + ... + + func floatingPanel( + _ fpc: FloatingPanelController, + shouldAllowToScroll trackingScrollView: UIScrollView, + in state: FloatingPanelState + ) -> Bool { + return state == .full || state == .half + } +} +``` + +## Notes + +### 'Show' or 'Show Detail' Segues from `FloatingPanelController`'s content view controller + +'Show' or 'Show Detail' segues from a content view controller will be managed by a view controller(hereinafter called 'master VC') adding a floating panel. Because a floating panel is just a subview of the master VC(except for modality). + +`FloatingPanelController` has no way to manage a stack of view controllers like `UINavigationController`. If so, it would be so complicated and the interface will become `UINavigationController`. This component should not have the responsibility to manage the stack. + +By the way, a content view controller can present a view controller modally with `present(_:animated:completion:)` or 'Present Modally' segue. + +However, sometimes you want to show a destination view controller of 'Show' or 'Show Detail' segue with another floating panel. It's possible to override `show(_:sender)` of the master VC! + +Here is an example. + +```swift +class ViewController: UIViewController { + var fpc: FloatingPanelController! + var secondFpc: FloatingPanelController! + + ... + override func show(_ vc: UIViewController, sender: Any?) { + secondFpc = FloatingPanelController() + + secondFpc.set(contentViewController: vc) + + secondFpc.addPanel(toParent: self) + } +} +``` + +A `FloatingPanelController` object proxies an action for `show(_:sender)` to the master VC. That's why the master VC can handle a destination view controller of a 'Show' or 'Show Detail' segue and you can hook `show(_:sender)` to show a secondary floating panel set the destination view controller to the content. + +It's a great way to decouple between a floating panel and the content VC. + +### UISearchController issue + +`UISearchController` isn't able to be used with `FloatingPanelController` by the system design. + +Because `UISearchController` automatically presents itself modally when a user interacts with the search bar, and then it swaps the superview of the search bar to the view managed by itself while it displays. As a result, `FloatingPanelController` can't control the search bar when it's active, as you can see from [the screen shot](https://github.com/SCENEE/FloatingPanel/issues/248#issuecomment-521263831). diff --git a/Documentation/FloatingPanel SwiftUI API Guide.md b/Documentation/FloatingPanel SwiftUI API Guide.md new file mode 100644 index 000000000..0d5c07944 --- /dev/null +++ b/Documentation/FloatingPanel SwiftUI API Guide.md @@ -0,0 +1,256 @@ +# FloatingPanel SwiftUI API Guide + +## Table of Contents + +
+ +Click here + + +- [Table of Contents](#table-of-contents) +- [Requirements](#requirements) +- [Goals](#goals) +- [Non-Goals](#non-goals) +- [Design Principles](#design-principles) +- [Development and compatibility](#development-and-compatibility) +- [API implementation considerations](#api-implementation-considerations) +- [Supplemental view approach rather than modal presentation](#supplemental-view-approach-rather-than-modal-presentation) +- [Leveraging the view modifiers](#leveraging-the-view-modifiers) +- [`FloatingPanelCoordinator`: The key component](#floatingpanelcoordinator-the-key-component) + - [Core Responsibilities](#core-responsibilities) + - [Default Implementation](#default-implementation) + - [Use Cases](#use-cases) + - [1. Custom Event Handling](#1-custom-event-handling) + - [2. Responding to Environment Changes](#2-responding-to-environment-changes) + - [3. Modal Presentation](#3-modal-presentation) + - [Best Practices](#best-practices) + - [Define Meaningful Events](#define-meaningful-events) + - [Use Lazy Delegate Initialization](#use-lazy-delegate-initialization) + - [Handle Environment Changes Efficiently](#handle-environment-changes-efficiently) + - [Coordinate with SwiftUI Animations](#coordinate-with-swiftui-animations) + +
+ +## Requirements + +- iOS 15 or later +- Xcode 16 or later + +> [!NOTE] +> The SwiftUI API can be used on iOS 14, but it's out of the supported versions. + +## Goals + +1. Build SwiftUI APIs on top of our battle-tested UIKit implementation +2. Enable smooth interoperability between UIKit and SwiftUI components +3. Provide an idiomatic SwiftUI developer experience + +## Non-Goals + +1. Complete reimplementation of FloatingPanel solely using SwiftUI APIs +2. Cover all UIKit-specific customization options through SwiftUI APIs + +## Design Principles + +- APIs designed to maximize user control and flexibility +- FloatingPanel designed as a supplemental view rather than a modal presentation as the same as UIKit implementation. +- Declarative modifiers that follow SwiftUI conventions +- Environment-based configuration patterns +- Seamless integration with SwiftUI's view hierarchy +- Implementation of essential APIs only, with plans to expand based on user requests -- we welcome your feedback! + +## Development and compatibility + +- Built targeting Xcode 16 as the primary development environment + - Maintains backward compatibility with Xcode 14/15 for UIKit support but gradually migrates to full Xcode 16+ features +- iOS version compatibility: + - Our SwiftUI integration builds for iOS 14 or later, but has been primarily tested on iOS 15+ + +## API implementation considerations + +- We've determined that providing an integration API is the optimal approach with enhanced UIKit integration (as of iOS 18) +- Currently not using `@Entry` due to compatibility constraints + +## Supplemental view approach rather than modal presentation + +FloatingPanel has been designed as a supplemental view rather than a modal presentation since its first release in the UIKit implementation. The SwiftUI APIs follow this same principle, allowing users to leverage this library for use cases not covered by Apple's built-in APIs. + +For instance, the SwiftUI API deliberately doesn't provide an `isPresented` binding argument in the `floatingPanel(coordinator:onEvent:content:)` modifier. If you want to hide a floating panel, use the `.hidden` anchor state instead, which enables you to hide a floating panel while keeping the content pre-rendered outside the visible screen area. + +## Leveraging the view modifiers + +The SwiftUI APIs provide a variety of view modifiers. Consider using these modifiers before implementing custom logic in your `FloatingPanelCoordinator` object. + +## `FloatingPanelCoordinator`: The key component + +The `FloatingPanelCoordinator` protocol is a key component in the FloatingPanel's SwiftUI integration, serving as the bridge between SwiftUI's declarative UI framework and FloatingPanel's UIKit-based implementation. + +It manages the connection between the SwiftUI view hierarchy and the underlying `FloatingPanelController`, handling setup, configuration, and event dispatching for floating panels within SwiftUI applications. + +This secion explains the `FloatingPanelCoordinator` protocol in detail, including its purpose, implementation patterns, and common use cases to help you effectively integrate floating panels into your SwiftUI applications. + +### Core Responsibilities + +A `FloatingPanelCoordinator` implementation handles the following key responsibilities: + +1. **Creation and Configuration**: Initializing and configuring the underlying `FloatingPanelController` instance +2. **View Hierarchy Management**: Setting up the relationship between the main SwiftUI view and the panel content view +3. **State Management**: Handling panel state transitions and position changes +4. **Event Handling**: Capturing panel events and dispatching them back to SwiftUI +5. **Environment Changes**: Responding to SwiftUI environment changes and updating the panel accordingly + +### Default Implementation + +The library provides [`FloatingPanelDefaultCoordinator`](Sources/SwiftUI/FloatingPanelCoordinator.swift) as a standard implementation for basic panel integration. + +`FloatingPanelCoordinator` intentionally does not provide default implementations for its required methods. This design choice avoids implicit behavior when handling the `FloatingPanelController`. When implementing a custom coordinator instead of using `FloatingPanelDefaultCoordinator`, users can clearly understand the requirements of the `FloatingPanelCoordinator` protocol and how the `FloatingPanelController` should be managed in their custom implementation. + +### Use Cases + +#### 1. Custom Event Handling + +Define a custom coordinator to handle panel events: + +```swift +struct ContentView: View { + var body: some View { + Color.blue + .ignoresSafeArea() + .floatingPanel( + coordinator: MyPanelCoordinator.self, + onEvent: handlePanelEvent + ) { proxy in + PanelContent(proxy: proxy) + } + } + + func handlePanelEvent(_ event: MyPanelCoordinator.Event) { + switch event { + case .willChangeState(let state): + print("Panel will change to \(state)") + case .didChangeState(let state): + print("Panel changed to \(state)") + } + } +} + +class MyPanelCoordinator: FloatingPanelCoordinator { + enum Event { + case willChangeState(FloatingPanelState) + case didChangeState(FloatingPanelState) + } + + let action: (Event) -> Void + lazy var delegate: FloatingPanelControllerDelegate? = self + let proxy: FloatingPanelProxy + + ... +} + +extension MyPanelCoordinator: FloatingPanelControllerDelegate { + func floatingPanelWillBeginDragging(_ fpc: FloatingPanelController) { + action(.willChangeState(fpc.state)) + } + + func floatingPanelDidChangeState(_ fpc: FloatingPanelController) { + action(.didChangeState(fpc.state)) + } +} +``` + +#### 2. Responding to Environment Changes + +Create a coordinator that responds to SwiftUI environment changes: + +```swift +class EnvironmentAwarePanelCoordinator: FloatingPanelCoordinator { + ... + func onUpdate( + context: UIViewControllerRepresentableContext + ) where Representable: UIViewControllerRepresentable { + // Access environment values and update the panel + let shouldMoveToFullState = context.environment.someCustomValue + if shouldMoveToFullState { + proxy.move(to: .full, animated: true) + } + } +} +``` + +#### 3. Modal Presentation + +Implement a coordinator that presents the panel modally: + +```swift +class ModalPanelCoordinator: FloatingPanelCoordinator { + enum Event { + case dismissed + } + + let action: (Event) -> Void + let proxy: FloatingPanelProxy + lazy var delegate: FloatingPanelControllerDelegate? = nil + + ... + func setupFloatingPanel( + mainHostingController: UIHostingController
, + contentHostingController: UIHostingController + ) where Main: View, Content: View { + // Set up the content + contentHostingController.view.backgroundColor = .clear + controller.set(contentViewController: contentHostingController) + + // Present the panel modally + Task { @MainActor in + controller.isRemovalInteractionEnabled = true + controller.delegate = self + mainHostingController.present(controller, animated: true) + } + } + + ... +} + +extension ModalPanelCoordinator: FloatingPanelControllerDelegate { + func floatingPanelDidEndRemove(_ fpc: FloatingPanelController) { + action(.dismissed) + } +} +``` + +### Best Practices + +#### Define Meaningful Events + +Design your `Event` type to capture meaningful panel interactions that SwiftUI views need to respond to, but avoid over-engineering with too many event types. + +#### Use Lazy Delegate Initialization + +Initialize `delegate` lazily when you want your coordinator to implement `FloatingPanelControllerDelegate`: + +```swift +lazy var delegate: FloatingPanelControllerDelegate? = self +``` + +#### Handle Environment Changes Efficiently + +In your `onUpdate` method, compare environment values with current panel state before making changes to avoid unnecessary updates. + +#### Coordinate with SwiftUI Animations + +Respect SwiftUI's animation context when moving the panel on iOS 18 or later: + +```swift +func onUpdate( + context: UIViewControllerRepresentableContext +) where Representable: UIViewControllerRepresentable { + if #available(iOS 18.0, *) { + let animation = context.transaction.animation ?? .spring(response: 0.25, dampingFraction: 0.9) + UIView.animate(animation) { + proxy.move(to: .full, animated: false) + } + } else { + ... + } +} +``` diff --git a/assets/maps-landscape.gif b/Documentation/assets/maps-landscape.gif similarity index 100% rename from assets/maps-landscape.gif rename to Documentation/assets/maps-landscape.gif diff --git a/assets/maps.gif b/Documentation/assets/maps.gif similarity index 100% rename from assets/maps.gif rename to Documentation/assets/maps.gif diff --git a/assets/stocks.gif b/Documentation/assets/stocks.gif similarity index 100% rename from assets/stocks.gif rename to Documentation/assets/stocks.gif diff --git a/Examples/Maps-SwiftUI/Maps-SwiftUI.xcodeproj/project.pbxproj b/Examples/Maps-SwiftUI/Maps-SwiftUI.xcodeproj/project.pbxproj index 71e73f6af..1163657ec 100644 --- a/Examples/Maps-SwiftUI/Maps-SwiftUI.xcodeproj/project.pbxproj +++ b/Examples/Maps-SwiftUI/Maps-SwiftUI.xcodeproj/project.pbxproj @@ -7,10 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 54476E712DBA5F3C00603086 /* UIHostingController+ignoreKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54476E702DBA5F3C00603086 /* UIHostingController+ignoreKeyboard.swift */; }; 6467E8642699AC5F00565F4F /* SurfaceAppearance+phone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6467E8632699AC5F00565F4F /* SurfaceAppearance+phone.swift */; }; 6467E86A2699B19D00565F4F /* SearchPanelPhoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6467E8692699B19D00565F4F /* SearchPanelPhoneDelegate.swift */; }; - 649A122926C14D0900DAB961 /* UIHostingController+ignoreKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649A122826C14D0900DAB961 /* UIHostingController+ignoreKeyboard.swift */; }; - 649A122D26C168CF00DAB961 /* View+floatingPanelGrabberHandlePadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649A122C26C168CF00DAB961 /* View+floatingPanelGrabberHandlePadding.swift */; }; 64A5B7232691323900BCAA05 /* MapsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A5B7222691323900BCAA05 /* MapsApp.swift */; }; 64A5B7252691323900BCAA05 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A5B7242691323900BCAA05 /* ContentView.swift */; }; 64A5B734269133DC00BCAA05 /* FloatingPanel.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A5B733269133DC00BCAA05 /* FloatingPanel.framework */; }; @@ -20,11 +19,6 @@ 64A5B73E269147DC00BCAA05 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A5B73D269147DC00BCAA05 /* SearchBar.swift */; }; 64A5B7402691532400BCAA05 /* ResultsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A5B73F2691532400BCAA05 /* ResultsList.swift */; }; 64A5B7422691541A00BCAA05 /* HostingCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A5B7412691541A00BCAA05 /* HostingCell.swift */; }; - 64F7E83126AD70EB00A0E0F7 /* View+floatingPanelContentInsetAdjustmentBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F7E82C26AD70EB00A0E0F7 /* View+floatingPanelContentInsetAdjustmentBehavior.swift */; }; - 64F7E83226AD70EB00A0E0F7 /* View+floatingPanelContentMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F7E82D26AD70EB00A0E0F7 /* View+floatingPanelContentMode.swift */; }; - 64F7E83326AD70EB00A0E0F7 /* FloatingPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F7E82E26AD70EB00A0E0F7 /* FloatingPanelView.swift */; }; - 64F7E83426AD70EB00A0E0F7 /* View+floatingPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F7E82F26AD70EB00A0E0F7 /* View+floatingPanel.swift */; }; - 64F7E83526AD70EB00A0E0F7 /* View+floatingPanelSurfaceAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F7E83026AD70EB00A0E0F7 /* View+floatingPanelSurfaceAppearance.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -42,10 +36,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 54476E702DBA5F3C00603086 /* UIHostingController+ignoreKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIHostingController+ignoreKeyboard.swift"; sourceTree = ""; }; 6467E8632699AC5F00565F4F /* SurfaceAppearance+phone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceAppearance+phone.swift"; sourceTree = ""; }; 6467E8692699B19D00565F4F /* SearchPanelPhoneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchPanelPhoneDelegate.swift; sourceTree = ""; }; - 649A122826C14D0900DAB961 /* UIHostingController+ignoreKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIHostingController+ignoreKeyboard.swift"; sourceTree = ""; }; - 649A122C26C168CF00DAB961 /* View+floatingPanelGrabberHandlePadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+floatingPanelGrabberHandlePadding.swift"; sourceTree = ""; }; 64A5B71F2691323900BCAA05 /* Maps-SwiftUI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Maps-SwiftUI.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 64A5B7222691323900BCAA05 /* MapsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapsApp.swift; sourceTree = ""; }; 64A5B7242691323900BCAA05 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -55,11 +48,6 @@ 64A5B73D269147DC00BCAA05 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; 64A5B73F2691532400BCAA05 /* ResultsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultsList.swift; sourceTree = ""; }; 64A5B7412691541A00BCAA05 /* HostingCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingCell.swift; sourceTree = ""; }; - 64F7E82C26AD70EB00A0E0F7 /* View+floatingPanelContentInsetAdjustmentBehavior.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+floatingPanelContentInsetAdjustmentBehavior.swift"; sourceTree = ""; }; - 64F7E82D26AD70EB00A0E0F7 /* View+floatingPanelContentMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+floatingPanelContentMode.swift"; sourceTree = ""; }; - 64F7E82E26AD70EB00A0E0F7 /* FloatingPanelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FloatingPanelView.swift; sourceTree = ""; }; - 64F7E82F26AD70EB00A0E0F7 /* View+floatingPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+floatingPanel.swift"; sourceTree = ""; }; - 64F7E83026AD70EB00A0E0F7 /* View+floatingPanelSurfaceAppearance.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+floatingPanelSurfaceAppearance.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -94,10 +82,10 @@ 64A5B7212691323900BCAA05 /* Maps */ = { isa = PBXGroup; children = ( - 64F7E82B26AD70EB00A0E0F7 /* FloatingPanel */, 64BE55702691BBB0006D98BD /* Representable */, 64A5B7222691323900BCAA05 /* MapsApp.swift */, 64A5B7242691323900BCAA05 /* ContentView.swift */, + 54476E702DBA5F3C00603086 /* UIHostingController+ignoreKeyboard.swift */, 64A5B73B2691469900BCAA05 /* FloatingPanelContentView.swift */, 6467E8692699B19D00565F4F /* SearchPanelPhoneDelegate.swift */, 6467E8632699AC5F00565F4F /* SurfaceAppearance+phone.swift */, @@ -124,20 +112,6 @@ path = Representable; sourceTree = ""; }; - 64F7E82B26AD70EB00A0E0F7 /* FloatingPanel */ = { - isa = PBXGroup; - children = ( - 64F7E82E26AD70EB00A0E0F7 /* FloatingPanelView.swift */, - 64F7E82F26AD70EB00A0E0F7 /* View+floatingPanel.swift */, - 64F7E82C26AD70EB00A0E0F7 /* View+floatingPanelContentInsetAdjustmentBehavior.swift */, - 64F7E82D26AD70EB00A0E0F7 /* View+floatingPanelContentMode.swift */, - 64F7E83026AD70EB00A0E0F7 /* View+floatingPanelSurfaceAppearance.swift */, - 649A122C26C168CF00DAB961 /* View+floatingPanelGrabberHandlePadding.swift */, - 649A122826C14D0900DAB961 /* UIHostingController+ignoreKeyboard.swift */, - ); - path = FloatingPanel; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -212,17 +186,11 @@ 64A5B738269134CA00BCAA05 /* VisualEffectBlur.swift in Sources */, 64A5B7402691532400BCAA05 /* ResultsList.swift in Sources */, 6467E8642699AC5F00565F4F /* SurfaceAppearance+phone.swift in Sources */, - 64F7E83226AD70EB00A0E0F7 /* View+floatingPanelContentMode.swift in Sources */, + 54476E712DBA5F3C00603086 /* UIHostingController+ignoreKeyboard.swift in Sources */, 64A5B7252691323900BCAA05 /* ContentView.swift in Sources */, 64A5B7232691323900BCAA05 /* MapsApp.swift in Sources */, 64A5B73C2691469900BCAA05 /* FloatingPanelContentView.swift in Sources */, - 649A122D26C168CF00DAB961 /* View+floatingPanelGrabberHandlePadding.swift in Sources */, - 64F7E83326AD70EB00A0E0F7 /* FloatingPanelView.swift in Sources */, - 64F7E83426AD70EB00A0E0F7 /* View+floatingPanel.swift in Sources */, - 64F7E83526AD70EB00A0E0F7 /* View+floatingPanelSurfaceAppearance.swift in Sources */, - 64F7E83126AD70EB00A0E0F7 /* View+floatingPanelContentInsetAdjustmentBehavior.swift in Sources */, 64A5B73E269147DC00BCAA05 /* SearchBar.swift in Sources */, - 649A122926C14D0900DAB961 /* UIHostingController+ignoreKeyboard.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Examples/Maps-SwiftUI/Maps/FloatingPanel/FloatingPanelView.swift b/Examples/Maps-SwiftUI/Maps/FloatingPanel/FloatingPanelView.swift deleted file mode 100644 index e03763231..000000000 --- a/Examples/Maps-SwiftUI/Maps/FloatingPanel/FloatingPanelView.swift +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. - -import FloatingPanel -import SwiftUI - -/// A proxy for exposing the methods of the floating panel controller. -public struct FloatingPanelProxy { - /// The associated floating panel controller. - public weak var fpc: FloatingPanelController? - - /// Tracks the specified scroll view to correspond with the scroll. - /// - /// - Parameter scrollView: Specify a scroll view to continuously and - /// seamlessly work in concert with interactions of the surface view. - public func track(scrollView: UIScrollView) { - fpc?.track(scrollView: scrollView) - } - - /// Moves the floating panel to the specified position. - /// - /// - Parameters: - /// - floatingPanelState: The state to move to. - /// - animated: `true` to animate the transition to the new state; `false` - /// otherwise. - public func move( - to floatingPanelState: FloatingPanelState, - animated: Bool, - completion: (() -> Void)? = nil - ) { - fpc?.move(to: floatingPanelState, animated: animated, completion: completion) - } -} - -/// A view with an associated floating panel. -struct FloatingPanelView: UIViewControllerRepresentable { - /// A type that conforms to the `FloatingPanelControllerDelegate` protocol. - var delegate: FloatingPanelControllerDelegate? - - /// The behavior for determining the adjusted content offsets. - @Environment(\.contentInsetAdjustmentBehavior) var contentInsetAdjustmentBehavior - - /// Constants that define how a panel content fills in the surface. - @Environment(\.contentMode) var contentMode - - /// The floating panel grabber handle offset. - @Environment(\.grabberHandlePadding) var grabberHandlePadding - - /// The floating panel `surfaceView` appearance. - @Environment(\.surfaceAppearance) var surfaceAppearance - - /// The view builder that creates the floating panel parent view content. - @ViewBuilder var content: Content - - /// The view builder that creates the floating panel content. - @ViewBuilder var floatingPanelContent: (FloatingPanelProxy) -> FloatingPanelContent - - public func makeUIViewController(context: Context) -> UIHostingController { - let hostingController = UIHostingController(rootView: content) - hostingController.view.backgroundColor = nil - // We need to wait for the current runloop cycle to complete before our - // view is actually added (into the view hierarchy), otherwise the - // environment is not ready yet. - DispatchQueue.main.async { - context.coordinator.setupFloatingPanel(hostingController) - } - return hostingController - } - - public func updateUIViewController( - _ uiViewController: UIHostingController, - context: Context - ) { - context.coordinator.updateIfNeeded() - } - - public func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - /// `FloatingPanelView` coordinator. - /// - /// Responsible to setup the view hierarchy and floating panel. - final class Coordinator { - private let parent: FloatingPanelView - private lazy var fpc = FloatingPanelController() - - init(parent: FloatingPanelView) { - self.parent = parent - } - - func setupFloatingPanel(_ parentViewController: UIViewController) { - updateIfNeeded() - let panelContent = parent.floatingPanelContent(FloatingPanelProxy(fpc: fpc)) - let hostingViewController = UIHostingController( - rootView: panelContent, - ignoresKeyboard: true - ) - hostingViewController.view.backgroundColor = nil - let contentViewController = UIViewController() - contentViewController.view.addSubview(hostingViewController.view) - fpc.set(contentViewController: contentViewController) - fpc.addPanel(toParent: parentViewController, animated: false) - - hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false - let bottomConstraint = hostingViewController.view.bottomAnchor.constraint(equalTo: contentViewController.view.bottomAnchor) - bottomConstraint.priority = .defaultHigh - NSLayoutConstraint.activate([ - hostingViewController.view.topAnchor.constraint(equalTo: contentViewController.view.topAnchor), - hostingViewController.view.leadingAnchor.constraint(equalTo: contentViewController.view.leadingAnchor), - hostingViewController.view.trailingAnchor.constraint(equalTo: contentViewController.view.trailingAnchor), - bottomConstraint - ]) - } - - func updateIfNeeded() { - if fpc.contentInsetAdjustmentBehavior != parent.contentInsetAdjustmentBehavior { - fpc.contentInsetAdjustmentBehavior = parent.contentInsetAdjustmentBehavior - } - if fpc.contentMode != parent.contentMode { - fpc.contentMode = parent.contentMode - } - if fpc.delegate !== parent.delegate { - fpc.delegate = parent.delegate - } - if fpc.surfaceView.grabberHandlePadding != parent.grabberHandlePadding { - fpc.surfaceView.grabberHandlePadding = parent.grabberHandlePadding - } - if fpc.surfaceView.appearance != parent.surfaceAppearance { - fpc.surfaceView.appearance = parent.surfaceAppearance - } - } - } -} diff --git a/Examples/Maps-SwiftUI/Maps/FloatingPanel/UIHostingController+ignoreKeyboard.swift b/Examples/Maps-SwiftUI/Maps/FloatingPanel/UIHostingController+ignoreKeyboard.swift deleted file mode 100644 index 7d9e53083..000000000 --- a/Examples/Maps-SwiftUI/Maps/FloatingPanel/UIHostingController+ignoreKeyboard.swift +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. - -import SwiftUI - -/// This extension makes sure SwiftUI views are not affected by iOS keyboard. -/// -/// Credits to https://steipete.me/posts/disabling-keyboard-avoidance-in-swiftui-uihostingcontroller/ -extension UIHostingController { - public convenience init(rootView: Content, ignoresKeyboard: Bool) { - self.init(rootView: rootView) - - if ignoresKeyboard { - guard let viewClass = object_getClass(view) else { return } - - let viewSubclassName = String( - cString: class_getName(viewClass) - ).appending("_IgnoresKeyboard") - - if let viewSubclass = NSClassFromString(viewSubclassName) { - object_setClass(view, viewSubclass) - } else { - guard - let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String, - let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) - else { return } - - if let method = class_getInstanceMethod( - viewClass, - NSSelectorFromString("keyboardWillShowWithNotification:") - ) { - let keyboardWillShow: @convention(block) (AnyObject, AnyObject) -> Void = { _, _ in } - class_addMethod( - viewSubclass, - NSSelectorFromString("keyboardWillShowWithNotification:"), - imp_implementationWithBlock(keyboardWillShow), - method_getTypeEncoding(method) - ) - } - objc_registerClassPair(viewSubclass) - object_setClass(view, viewSubclass) - } - } - } -} diff --git a/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanel.swift b/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanel.swift deleted file mode 100644 index 43ed1c3d1..000000000 --- a/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanel.swift +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. - -import FloatingPanel -import SwiftUI - -extension View { - /// Presents a floating panel using the given closure as its content. - /// - /// The modifier's content view builder receives a `FloatingPanelProxy` - /// instance; you use the proxy's methods to interact with the associated - /// `FloatingPanelController`. - /// - /// - Parameters: - /// - delegate: A type that conforms to the - /// `FloatingPanelControllerDelegate` protocol. You have comprehensive - /// control over the floating panel behavior when you use a delegate. - /// - floatingPanelContent: The floating panel content. This view builder - /// receives a `FloatingPanelProxy` instance that you use to interact - /// with the `FloatingPanelController`. - public func floatingPanel( - delegate: FloatingPanelControllerDelegate? = nil, - @ViewBuilder _ floatingPanelContent: @escaping (_: FloatingPanelProxy) -> FloatingPanelContent - ) -> some View { - FloatingPanelView( - delegate: delegate, - content: { self }, - floatingPanelContent: floatingPanelContent - ) - .ignoresSafeArea() - } -} diff --git a/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanelContentInsetAdjustmentBehavior.swift b/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanelContentInsetAdjustmentBehavior.swift deleted file mode 100644 index 99094dc35..000000000 --- a/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanelContentInsetAdjustmentBehavior.swift +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. - -import FloatingPanel -import SwiftUI - -struct ContentInsetKey: EnvironmentKey { - static var defaultValue: FloatingPanelController.ContentInsetAdjustmentBehavior = .always -} - -extension EnvironmentValues { - /// The behavior for determining the adjusted content offsets. - var contentInsetAdjustmentBehavior: FloatingPanelController.ContentInsetAdjustmentBehavior { - get { self[ContentInsetKey.self] } - set { self[ContentInsetKey.self] = newValue } - } -} - -extension View { - /// Sets the content inset adjustment behavior for floating panels within - /// this view. - /// - /// Use this modifier to set a specific content inset adjustment behavior - /// for floating panel instances within a view: - /// - /// MainView() - /// .floatingPanel { _ in - /// FloatingPanelContent() - /// } - /// .floatingPanelContentInsetAdjustmentBehavior(.never) - /// - /// - Parameter contentInsetAdjustmentBehavior: The content inset adjustment - /// behavior to set. - public func floatingPanelContentInsetAdjustmentBehavior( - _ contentInsetAdjustmentBehavior: FloatingPanelController.ContentInsetAdjustmentBehavior - ) -> some View { - environment(\.contentInsetAdjustmentBehavior, contentInsetAdjustmentBehavior) - } -} diff --git a/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanelContentMode.swift b/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanelContentMode.swift deleted file mode 100644 index 05594c2f9..000000000 --- a/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanelContentMode.swift +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. - -import FloatingPanel -import SwiftUI - -struct ContentModeKey: EnvironmentKey { - static var defaultValue: FloatingPanelController.ContentMode = .static -} - -extension EnvironmentValues { - /// Used to determine how the floating panel controller lays out the content - /// view when the surface position changes. - var contentMode: FloatingPanelController.ContentMode { - get { self[ContentModeKey.self] } - set { self[ContentModeKey.self] = newValue } - } -} - -extension View { - /// Sets the content mode for floating panels within this view. - /// - /// Use this modifier to set a specific content mode for floating panel - /// instances within a view: - /// - /// MainView() - /// .floatingPanel { _ in - /// FloatingPanelContent() - /// } - /// .floatingPanelContentMode(.static) - /// - /// - Parameter contentMode: The content mode to set. - public func floatingPanelContentMode( - _ contentMode: FloatingPanelController.ContentMode - ) -> some View { - environment(\.contentMode, contentMode) - } -} diff --git a/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanelGrabberHandlePadding.swift b/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanelGrabberHandlePadding.swift deleted file mode 100644 index 64ff6e106..000000000 --- a/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanelGrabberHandlePadding.swift +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. - -import FloatingPanel -import SwiftUI - -struct GrabberHandlePaddingKey: EnvironmentKey { - static var defaultValue: CGFloat = 6.0 -} - -extension EnvironmentValues { - /// The offset of the grabber handle from the interactive edge. - var grabberHandlePadding: CGFloat { - get { self[GrabberHandlePaddingKey.self] } - set { self[GrabberHandlePaddingKey.self] = newValue } - } -} - -extension View { - /// Sets the grabber handle padding for floating panels within this view. - /// - /// Use this modifier to set a specific padding to floating panel instances - /// within a view: - /// - /// MainView() - /// .floatingPanel { _ in - /// FloatingPanelContent() - /// } - /// .floatingPanelGrabberHandlePadding(16) - /// - /// - Parameter padding: The grabber handle padding to set. - public func floatingPanelGrabberHandlePadding( - _ padding: CGFloat - ) -> some View { - environment(\.grabberHandlePadding, padding) - } -} diff --git a/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanelSurfaceAppearance.swift b/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanelSurfaceAppearance.swift deleted file mode 100644 index 0f0045418..000000000 --- a/Examples/Maps-SwiftUI/Maps/FloatingPanel/View+floatingPanelSurfaceAppearance.swift +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. - -import FloatingPanel -import SwiftUI - -struct SurfaceAppearanceKey: EnvironmentKey { - static var defaultValue = SurfaceAppearance() -} - -extension EnvironmentValues { - /// The appearance of a surface view. - var surfaceAppearance: SurfaceAppearance { - get { self[SurfaceAppearanceKey.self] } - set { self[SurfaceAppearanceKey.self] = newValue } - } -} - -extension View { - /// Sets the surface appearance for floating panels within this view. - /// - /// Use this modifier to set a specific surface appearance for floating - /// panel instances within a view: - /// - /// MainView() - /// .floatingPanel { _ in - /// FloatingPanelContent() - /// } - /// .floatingPanelSurfaceAppearance(.transparent) - /// - /// extension SurfaceAppearance { - /// static var transparent: SurfaceAppearance { - /// let appearance = SurfaceAppearance() - /// appearance.backgroundColor = .clear - /// return appearance - /// } - /// } - /// - /// - Parameter surfaceAppearance: The surface appearance to set. - public func floatingPanelSurfaceAppearance( - _ surfaceAppearance: SurfaceAppearance - ) -> some View { - environment(\.surfaceAppearance, surfaceAppearance) - } -} diff --git a/Examples/Maps-SwiftUI/Maps/FloatingPanelContentView.swift b/Examples/Maps-SwiftUI/Maps/FloatingPanelContentView.swift index 1be68d935..e82c31727 100644 --- a/Examples/Maps-SwiftUI/Maps/FloatingPanelContentView.swift +++ b/Examples/Maps-SwiftUI/Maps/FloatingPanelContentView.swift @@ -1,6 +1,7 @@ // Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. import SwiftUI +import FloatingPanel struct FloatingPanelContentView: View { @State private var searchText = "" @@ -10,7 +11,9 @@ struct FloatingPanelContentView: View { var body: some View { VStack(spacing: 0) { searchBar - resultsList + ResultsList() + .ignoresSafeArea() // Needs here with `floatingPanelScrollTracking(proxy:)` on iOS 15 + .floatingPanelScrollTracking(proxy: proxy) } // 👇🏻 for the floating panel grabber handle. .padding(.top, 6) @@ -36,8 +39,4 @@ struct FloatingPanelContentView: View { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } } - - var resultsList: some View { - ResultsList(onScrollViewCreated: proxy.track(scrollView:)) - } } diff --git a/Examples/Maps-SwiftUI/Maps/MapsApp.swift b/Examples/Maps-SwiftUI/Maps/MapsApp.swift index 78a40f38b..325b67e97 100644 --- a/Examples/Maps-SwiftUI/Maps/MapsApp.swift +++ b/Examples/Maps-SwiftUI/Maps/MapsApp.swift @@ -1,5 +1,6 @@ // Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. +import FloatingPanel import SwiftUI @main @@ -7,7 +8,9 @@ struct MapsApp: App { var body: some Scene { WindowGroup { ContentView() - .floatingPanel(delegate: SearchPanelPhoneDelegate()) { proxy in + .floatingPanel( + coordinator: MapPanelCoordinator.self + ) { proxy in FloatingPanelContentView(proxy: proxy) } .floatingPanelSurfaceAppearance(.phone) @@ -15,4 +18,92 @@ struct MapsApp: App { .floatingPanelContentInsetAdjustmentBehavior(.never) } } + func onFloatingPanelEvent(_ event: MapPanelCoordinator.Event) {} +} + +final class MapPanelCoordinator: FloatingPanelCoordinator { + enum Event {} + + let action: (Event) -> Void + let proxy: FloatingPanelProxy + + private lazy var delegate: FloatingPanelControllerDelegate? = self + + init(action: @escaping (Event) -> Void) { + self.action = action + self.proxy = .init(controller: FloatingPanelController()) + } + + public func setupFloatingPanel( + mainHostingController: UIHostingController
, + contentHostingController: UIHostingController + ) { + mainHostingController.ignoresKeyboardSafeArea() + contentHostingController.ignoresKeyboardSafeArea() + + if #available(iOS 16, *) { + if #unavailable(iOS 26) { + // Set the delegate object + controller.delegate = delegate + + // Set up the content + contentHostingController.view.backgroundColor = nil + controller.set(contentViewController: contentHostingController) + + // Show the panel + controller.addPanel(toParent: mainHostingController, animated: false) + return + } + } + + // NOTE: Fix floating panel content view constraints (#549) + // This issue happens on iOS 15 or earlier, and iOS 26 or later. + + // Set the delegate object + controller.delegate = delegate + + // Set up the content + contentHostingController.view.backgroundColor = nil + let contentWrapperViewController = UIViewController() + contentWrapperViewController.view.addSubview(contentHostingController.view) + contentWrapperViewController.addChild(contentHostingController) + contentHostingController.didMove(toParent: contentWrapperViewController) + controller.set(contentViewController: contentWrapperViewController) + + // Show the panel + controller.addPanel(toParent: mainHostingController, animated: false) + + contentHostingController.view.translatesAutoresizingMaskIntoConstraints = false + let bottomConstraint = contentHostingController.view.bottomAnchor.constraint( + equalTo: contentWrapperViewController.view.bottomAnchor + ) + bottomConstraint.priority = .defaultHigh + NSLayoutConstraint.activate([ + contentHostingController.view.topAnchor.constraint( + equalTo: contentWrapperViewController.view.topAnchor + ), + contentHostingController.view.leadingAnchor.constraint( + equalTo: contentWrapperViewController.view.leadingAnchor + ), + contentHostingController.view.trailingAnchor.constraint( + equalTo: contentWrapperViewController.view.trailingAnchor + ), + bottomConstraint, + ]) + } + + func onUpdate( + context: UIViewControllerRepresentableContext + ) where Representable: UIViewControllerRepresentable {} +} + +extension MapPanelCoordinator: FloatingPanelControllerDelegate { + func floatingPanelWillBeginAttracting( + _ fpc: FloatingPanelController, + to state: FloatingPanelState + ) { + if fpc.state == .full { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + } } diff --git a/Examples/Maps-SwiftUI/Maps/ResultsList.swift b/Examples/Maps-SwiftUI/Maps/ResultsList.swift index df7c5dee5..372d2dbda 100644 --- a/Examples/Maps-SwiftUI/Maps/ResultsList.swift +++ b/Examples/Maps-SwiftUI/Maps/ResultsList.swift @@ -3,21 +3,16 @@ import SwiftUI struct ResultsList: UIViewControllerRepresentable { - var onScrollViewCreated: (_ scrollView: UIScrollView) -> Void - func makeUIViewController( context: Context ) -> ResultsTableViewController { - let rtvc = ResultsTableViewController() - onScrollViewCreated(rtvc.tableView) - return rtvc + ResultsTableViewController() } func updateUIViewController( _ uiViewController: ResultsTableViewController, context: Context - ) { - } + ) {} } final class ResultsTableViewController: UITableViewController { diff --git a/Examples/Maps-SwiftUI/Maps/UIHostingController+ignoreKeyboard.swift b/Examples/Maps-SwiftUI/Maps/UIHostingController+ignoreKeyboard.swift new file mode 100644 index 000000000..3acd78118 --- /dev/null +++ b/Examples/Maps-SwiftUI/Maps/UIHostingController+ignoreKeyboard.swift @@ -0,0 +1,46 @@ +// Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. + +import SwiftUI + +/// This extension makes sure SwiftUI views are not affected by iOS keyboard. +/// +/// Credits to https://steipete.me/posts/disabling-keyboard-avoidance-in-swiftui-uihostingcontroller/ +extension UIHostingController { + convenience init(rootView: Content, ignoresKeyboard: Bool) { + self.init(rootView: rootView) + if ignoresKeyboard { + ignoresKeyboardSafeArea() + } + } + func ignoresKeyboardSafeArea() { + guard let viewClass = object_getClass(view) else { return } + + let viewSubclassName = String( + cString: class_getName(viewClass) + ).appending("_IgnoresKeyboard") + + if let viewSubclass = NSClassFromString(viewSubclassName) { + object_setClass(view, viewSubclass) + } else { + guard + let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String, + let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) + else { return } + + if let method = class_getInstanceMethod( + viewClass, + NSSelectorFromString("keyboardWillShowWithNotification:") + ) { + let keyboardWillShow: @convention(block) (AnyObject, AnyObject) -> Void = { _, _ in } + class_addMethod( + viewSubclass, + NSSelectorFromString("keyboardWillShowWithNotification:"), + imp_implementationWithBlock(keyboardWillShow), + method_getTypeEncoding(method) + ) + } + objc_registerClassPair(viewSubclass) + object_setClass(view, viewSubclass) + } + } +} diff --git a/Examples/SamplesObjC/SamplesObjC.xcodeproj/project.pbxproj b/Examples/SamplesObjC/SamplesObjC.xcodeproj/project.pbxproj index c689452b3..346599ab6 100644 --- a/Examples/SamplesObjC/SamplesObjC.xcodeproj/project.pbxproj +++ b/Examples/SamplesObjC/SamplesObjC.xcodeproj/project.pbxproj @@ -324,7 +324,6 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = NO; - DEVELOPMENT_TEAM = J3D7L9FHSS; INFOPLIST_FILE = SamplesObjC/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -350,7 +349,6 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = NO; - DEVELOPMENT_TEAM = J3D7L9FHSS; INFOPLIST_FILE = SamplesObjC/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Examples/SamplesSwiftUI/README.md b/Examples/SamplesSwiftUI/README.md new file mode 100644 index 000000000..0f46e79ec --- /dev/null +++ b/Examples/SamplesSwiftUI/README.md @@ -0,0 +1,6 @@ +# SamplesSwiftUI + +## Requirements + +* iOS 15 or later +* Xcode 16 or later diff --git a/Examples/SamplesSwiftUI/SamplesSwiftUI.xcodeproj/project.pbxproj b/Examples/SamplesSwiftUI/SamplesSwiftUI.xcodeproj/project.pbxproj new file mode 100644 index 000000000..cdbe75e0e --- /dev/null +++ b/Examples/SamplesSwiftUI/SamplesSwiftUI.xcodeproj/project.pbxproj @@ -0,0 +1,364 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 549197B42CB41D48006C2328 /* FloatingPanel.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 549197B32CB41D48006C2328 /* FloatingPanel.framework */; }; + 549197B52CB41D48006C2328 /* FloatingPanel.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 549197B32CB41D48006C2328 /* FloatingPanel.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 54EF58B52DC8AAEE00B4880E /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 54EF58B42DC8AAEA00B4880E /* README.md */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 549197B62CB41D48006C2328 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 549197B52CB41D48006C2328 /* FloatingPanel.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 5491979E2CB41D01006C2328 /* SamplesSwiftUI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SamplesSwiftUI.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 549197B32CB41D48006C2328 /* FloatingPanel.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = FloatingPanel.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 54EF58B42DC8AAEA00B4880E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 549197A02CB41D01006C2328 /* SamplesSwiftUI */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SamplesSwiftUI; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 5491979B2CB41D01006C2328 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 549197B42CB41D48006C2328 /* FloatingPanel.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 549197952CB41D01006C2328 = { + isa = PBXGroup; + children = ( + 54EF58B42DC8AAEA00B4880E /* README.md */, + 549197A02CB41D01006C2328 /* SamplesSwiftUI */, + 549197B22CB41D48006C2328 /* Frameworks */, + 5491979F2CB41D01006C2328 /* Products */, + ); + sourceTree = ""; + }; + 5491979F2CB41D01006C2328 /* Products */ = { + isa = PBXGroup; + children = ( + 5491979E2CB41D01006C2328 /* SamplesSwiftUI.app */, + ); + name = Products; + sourceTree = ""; + }; + 549197B22CB41D48006C2328 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 549197B32CB41D48006C2328 /* FloatingPanel.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 5491979D2CB41D01006C2328 /* SamplesSwiftUI */ = { + isa = PBXNativeTarget; + buildConfigurationList = 549197AC2CB41D02006C2328 /* Build configuration list for PBXNativeTarget "SamplesSwiftUI" */; + buildPhases = ( + 5491979A2CB41D01006C2328 /* Sources */, + 5491979B2CB41D01006C2328 /* Frameworks */, + 5491979C2CB41D01006C2328 /* Resources */, + 549197B62CB41D48006C2328 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 549197A02CB41D01006C2328 /* SamplesSwiftUI */, + ); + name = SamplesSwiftUI; + packageProductDependencies = ( + ); + productName = SamplesSwiftUI; + productReference = 5491979E2CB41D01006C2328 /* SamplesSwiftUI.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 549197962CB41D01006C2328 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + 5491979D2CB41D01006C2328 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = 549197992CB41D01006C2328 /* Build configuration list for PBXProject "SamplesSwiftUI" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 549197952CB41D01006C2328; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 5491979F2CB41D01006C2328 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 5491979D2CB41D01006C2328 /* SamplesSwiftUI */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 5491979C2CB41D01006C2328 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 54EF58B52DC8AAEE00B4880E /* README.md in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 5491979A2CB41D01006C2328 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 549197AA2CB41D02006C2328 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 549197AB2CB41D02006C2328 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 549197AD2CB41D02006C2328 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"SamplesSwiftUI/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = example.SamplesSwiftUI; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 549197AE2CB41D02006C2328 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"SamplesSwiftUI/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = example.SamplesSwiftUI; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 549197992CB41D01006C2328 /* Build configuration list for PBXProject "SamplesSwiftUI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 549197AA2CB41D02006C2328 /* Debug */, + 549197AB2CB41D02006C2328 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 549197AC2CB41D02006C2328 /* Build configuration list for PBXNativeTarget "SamplesSwiftUI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 549197AD2CB41D02006C2328 /* Debug */, + 549197AE2CB41D02006C2328 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 549197962CB41D01006C2328 /* Project object */; +} diff --git a/Examples/SamplesSwiftUI/SamplesSwiftUI/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/SamplesSwiftUI/SamplesSwiftUI/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/Examples/SamplesSwiftUI/SamplesSwiftUI/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SamplesSwiftUI/SamplesSwiftUI/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/SamplesSwiftUI/SamplesSwiftUI/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/Examples/SamplesSwiftUI/SamplesSwiftUI/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SamplesSwiftUI/SamplesSwiftUI/Assets.xcassets/Contents.json b/Examples/SamplesSwiftUI/SamplesSwiftUI/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Examples/SamplesSwiftUI/SamplesSwiftUI/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SamplesSwiftUI/SamplesSwiftUI/Preview Content/Preview Assets.xcassets/Contents.json b/Examples/SamplesSwiftUI/SamplesSwiftUI/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Examples/SamplesSwiftUI/SamplesSwiftUI/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SamplesSwiftUI/SamplesSwiftUI/SampleApp.swift b/Examples/SamplesSwiftUI/SamplesSwiftUI/SampleApp.swift new file mode 100644 index 000000000..2e08eb8ab --- /dev/null +++ b/Examples/SamplesSwiftUI/SamplesSwiftUI/SampleApp.swift @@ -0,0 +1,12 @@ +// Copyright 2025 the FloatingPanel authors. All rights reserved. MIT license. + +import SwiftUI + +@main +struct SampleApp: App { + var body: some Scene { + WindowGroup { + MainView() + } + } +} diff --git a/Examples/SamplesSwiftUI/SamplesSwiftUI/UseCases/InsideTab.swift b/Examples/SamplesSwiftUI/SamplesSwiftUI/UseCases/InsideTab.swift new file mode 100644 index 000000000..fdfa1a7f9 --- /dev/null +++ b/Examples/SamplesSwiftUI/SamplesSwiftUI/UseCases/InsideTab.swift @@ -0,0 +1,34 @@ +// Copyright 2025 the FloatingPanel authors. All rights reserved. MIT license. + +import FloatingPanel +import SwiftUI + +struct InsideTab: View { + var body: some View { + if #available(iOS 18.0, *) { + TabView { + Tab("Main", systemImage: "lanyardcard") { + MainView() + } + Tab("Multi Panel", systemImage: "lanyardcard") { + MultiPanelView() + } + } + } else { + TabView { + MainView() + .tabItem { + Label("Main", systemImage: "lanyardcard") + } + MultiPanelView() + .tabItem { + Label("Multi Panel 22", systemImage: "lanyardcard") + } + } + } + } +} + +#Preview { + InsideTab() +} diff --git a/Examples/SamplesSwiftUI/SamplesSwiftUI/UseCases/MainView.swift b/Examples/SamplesSwiftUI/SamplesSwiftUI/UseCases/MainView.swift new file mode 100644 index 000000000..35412bb32 --- /dev/null +++ b/Examples/SamplesSwiftUI/SamplesSwiftUI/UseCases/MainView.swift @@ -0,0 +1,137 @@ +// Copyright 2025 the FloatingPanel authors. All rights reserved. MIT license. + +import FloatingPanel +import SwiftUI +import UIKit +import os.log + +struct MainView: View { + enum CardContent: String, CaseIterable, Identifiable { + case list + case detail + + var id: String { rawValue } + } + @State private var panelLayout: FloatingPanelLayout? = MyFloatingPanelLayout() + @State private var panelState: FloatingPanelState? + @State private var selectedContent: CardContent = .list + + var body: some View { + ZStack { + Color.orange + .ignoresSafeArea() + VStack(spacing: 32) { + Picker("type", selection: $selectedContent) { + ForEach(CardContent.allCases) { + type in + Text(type.rawValue).tag(type) + } + } + .pickerStyle(.segmented) + Button("Move to full") { + withAnimation(.interactiveSpring) { + panelState = .full + } + } + Button { + withAnimation(.interactiveSpring) { + if panelLayout is MyFloatingPanelLayout { + panelLayout = nil + } else { + panelLayout = MyFloatingPanelLayout() + } + } + } label: { + if panelLayout is MyFloatingPanelLayout { + Text("Switch to Default layout") + } else { + Text("Switch to My layout") + } + } + Spacer() + } + } + .floatingPanel( + coordinator: MyPanelCoordinator.self + ) { proxy in + switch selectedContent { + case .list: + ContentView(proxy: proxy) + case .detail: + HStack { + Spacer() + VStack { + Text("Detail content") + .padding(.top, 32) + Spacer() + } + Spacer() + } + .padding() + .background { + BackgroundView() + } + } + } + .floatingPanelSurfaceAppearance(.transparent()) + .floatingPanelLayout(panelLayout) + .floatingPanelState($panelState) + .onChange(of: panelState) { newValue in + Logger().debug("Panel state changed: \(newValue ?? .hidden)") + } + } +} + +// A custom coordinator object which handles panel context updates and setting up `FloatingPanelControllerDelegate` methods +class MyPanelCoordinator: FloatingPanelCoordinator { + enum Event {} + + let action: (Event) -> Void + let proxy: FloatingPanelProxy + + required init(action: @escaping (MyPanelCoordinator.Event) -> Void) { + self.action = action + self.proxy = .init(controller: FloatingPanelController()) + } + + func setupFloatingPanel( + mainHostingController: UIHostingController
, + contentHostingController: UIHostingController + ) where Main: View, Content: View { + // Set this as the delegate object + controller.delegate = self + + // Set up the content + contentHostingController.view.backgroundColor = .clear + controller.set(contentViewController: contentHostingController) + + // Show the panel + controller.addPanel(toParent: mainHostingController, animated: false) + } + + func onUpdate( + context: UIViewControllerRepresentableContext + ) where Representable: UIViewControllerRepresentable {} +} + +extension MyPanelCoordinator: FloatingPanelControllerDelegate { + func floatingPanelDidChangeState(_ fpc: FloatingPanelController) { + // NOTE: This timing is difference from one of the change of the binding value + // to `floatingPanelState(_:)` modifier + } +} + +// A custom layout object +class MyFloatingPanelLayout: FloatingPanelLayout { + let position: FloatingPanelPosition = .bottom + let initialState: FloatingPanelState = .tip + let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [ + .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea), + .half: FloatingPanelLayoutAnchor(fractionalInset: 0.4, edge: .bottom, referenceGuide: .safeArea), + .tip: FloatingPanelLayoutAnchor(absoluteInset: 44.0, edge: .bottom, referenceGuide: .safeArea), + ] +} + +#Preview("MainView") { + MainView() +} diff --git a/Examples/SamplesSwiftUI/SamplesSwiftUI/UseCases/MultiPanel.swift b/Examples/SamplesSwiftUI/SamplesSwiftUI/UseCases/MultiPanel.swift new file mode 100644 index 000000000..cb32bbfdf --- /dev/null +++ b/Examples/SamplesSwiftUI/SamplesSwiftUI/UseCases/MultiPanel.swift @@ -0,0 +1,31 @@ +// Copyright 2025 the FloatingPanel authors. All rights reserved. MIT license. + +import FloatingPanel +import SwiftUI + +struct MultiPanelView: View { + var body: some View { + ZStack { + Color.orange + .ignoresSafeArea() + .floatingPanel( + coordinator: MyPanelCoordinator.self + ) { proxy in + ContentView(proxy: proxy) + } + .floatingPanelSurfaceAppearance(.transparent()) + .floatingPanelContentMode(.fitToBounds) + .floatingPanel( + coordinator: MyPanelCoordinator.self + ) { proxy in + ContentView(proxy: proxy) + } + .floatingPanelContentMode(.static) + .floatingPanelSurfaceAppearance(.transparent(cornerRadius: 24)) + } + } +} + +#Preview { + MultiPanelView() +} diff --git a/Examples/SamplesSwiftUI/SamplesSwiftUI/Views/BackgroundView.swift b/Examples/SamplesSwiftUI/SamplesSwiftUI/Views/BackgroundView.swift new file mode 100644 index 000000000..0f9968eb3 --- /dev/null +++ b/Examples/SamplesSwiftUI/SamplesSwiftUI/Views/BackgroundView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct BackgroundView: View { + var body: some View { + GeometryReader { geometry in + Rectangle() + .fill(.clear) + .frame(height: geometry.size.height * 2) + .backgroundEffect() + } + } +} + +extension View { + @ViewBuilder + fileprivate func backgroundEffect() -> some View { + if #available(iOS 26, *) { + self.glassEffect(.regular, in: .rect) + } else { + self.background(.regularMaterial) + } + } +} diff --git a/Examples/SamplesSwiftUI/SamplesSwiftUI/Views/ContentView.swift b/Examples/SamplesSwiftUI/SamplesSwiftUI/Views/ContentView.swift new file mode 100644 index 000000000..8db4c47c3 --- /dev/null +++ b/Examples/SamplesSwiftUI/SamplesSwiftUI/Views/ContentView.swift @@ -0,0 +1,51 @@ +// Copyright 2025 the FloatingPanel authors. All rights reserved. MIT license. + +import FloatingPanel +import SwiftUI + +struct ContentView: View { + let proxy: FloatingPanelProxy + var body: some View { + Group { + if #available(iOS 17.0, *) { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(0...100, id: \.self) { i in + Text("Index \(i)") + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 60) + .background(.clear) + .padding(.horizontal) + } + } + } + .scrollClipDisabled() + .floatingPanelScrollTracking(proxy: proxy) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(0...100, id: \.self) { i in + Text("Index \(i)") + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 60) + .background(.clear) + .padding(.horizontal) + } + } + } + .floatingPanelScrollTracking(proxy: proxy) { scrollView, _ in + scrollView.clipsToBounds = false + } + } + } + // Prevent revealing underlying content at the bottom of the panel when the panel is moving beyond its fully‑expanded position. + .background { + BackgroundView() + } + } +} + +#Preview("ContentView") { + // `FloatingPanelProxy` can be instantiated like this. + ContentView(proxy: FloatingPanelProxy(controller: FloatingPanelController())) +} diff --git a/FloatingPanel.podspec b/FloatingPanel.podspec index e0dfc4d72..9d48e8608 100644 --- a/FloatingPanel.podspec +++ b/FloatingPanel.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "FloatingPanel" - s.version = "2.8.8" + s.version = "3.2.1" s.summary = "FloatingPanel is a clean and easy-to-use UI component of a floating panel interface." s.description = <<-DESC FloatingPanel is a clean and easy-to-use UI component for a new interface introduced in Apple Maps, Shortcuts and Stocks app. @@ -11,12 +11,13 @@ The new interface displays the related contents and utilities in parallel as a u s.author = "Shin Yamamoto" s.social_media_url = "https://x.com/scenee" - s.platform = :ios, "11.0" + s.platform = :ios, "12.0" s.source = { :git => "https://github.com/scenee/FloatingPanel.git", :tag => s.version.to_s } - s.source_files = "Sources/*.swift" + s.source_files = "Sources/**/*.swift" s.swift_version = '5.0' s.framework = "UIKit" s.license = { :type => "MIT", :file => "LICENSE" } end + diff --git a/FloatingPanel.xcodeproj/project.pbxproj b/FloatingPanel.xcodeproj/project.pbxproj index b603782f0..96e7c1dec 100644 --- a/FloatingPanel.xcodeproj/project.pbxproj +++ b/FloatingPanel.xcodeproj/project.pbxproj @@ -3,11 +3,16 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ 542753C622C49A6E00D17955 /* LayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542753C522C49A6E00D17955 /* LayoutTests.swift */; }; + 5431025B2DB8AAB800A927EF /* View+floatingPanelSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543102592DB8AAB800A927EF /* View+floatingPanelSurface.swift */; }; + 5431025E2DB8AAB800A927EF /* View+floatingPanelConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543102572DB8AAB800A927EF /* View+floatingPanelConfiguration.swift */; }; + 543102B22DB8B4AC00A927EF /* View+floatingPanelLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543102B12DB8B4A300A927EF /* View+floatingPanelLayout.swift */; }; + 543102EA2DB8F02E00A927EF /* View+floatingPanelBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543102E92DB8F02D00A927EF /* View+floatingPanelBehavior.swift */; }; + 543103472DB9B4D400A927EF /* View+floatingPanelScrollTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543103462DB9B4C900A927EF /* View+floatingPanelScrollTracking.swift */; }; 54352E9621A51A2500CBCA08 /* Transitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54352E9521A51A2500CBCA08 /* Transitioning.swift */; }; 54352E9821A521CA00CBCA08 /* PassthroughView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54352E9721A521CA00CBCA08 /* PassthroughView.swift */; }; 5450EEE421646DF500135936 /* Behavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5450EEE321646DF500135936 /* Behavior.swift */; }; @@ -21,7 +26,12 @@ 5469F4B024B30E1500537F8A /* LayoutAnchoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5469F4AF24B30E1500537F8A /* LayoutAnchoring.swift */; }; 5469F4B224B30F1100537F8A /* Position.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5469F4B124B30F1100537F8A /* Position.swift */; }; 5469F4B424B30F3500537F8A /* LayoutProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5469F4B324B30F3500537F8A /* LayoutProperties.swift */; }; + 5471AF8E2CB53FC8001B7A64 /* View+floatingPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5471AF8D2CB53FC8001B7A64 /* View+floatingPanel.swift */; }; + 5471AF902CB53FFC001B7A64 /* FloatingPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5471AF8F2CB53FFC001B7A64 /* FloatingPanelView.swift */; }; + 5471AF922CB5462C001B7A64 /* FloatingPanelProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5471AF912CB54628001B7A64 /* FloatingPanelProxy.swift */; }; + 547B44912CC0E8B300F7E68C /* FloatingPanelCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547B44902CC0E8AD00F7E68C /* FloatingPanelCoordinator.swift */; }; 547F7A9C2A6E946000303905 /* GestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547F7A9B2A6E946000303905 /* GestureTests.swift */; }; + 5484E7432DCF90AC0058E962 /* View+floatingPanelState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5484E7422DCF90A60058E962 /* View+floatingPanelState.swift */; }; 549C371F2361E15E007D8058 /* ExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549C371E2361E15D007D8058 /* ExtensionTests.swift */; }; 549E944522CF295D0050AECF /* StateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549E944422CF295D0050AECF /* StateTests.swift */; }; 54A6B6B122968B530077F348 /* CoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54A6B6B022968B530077F348 /* CoreTests.swift */; }; @@ -32,6 +42,7 @@ 54CFBFC3215CD045006B5735 /* Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CFBFC2215CD045006B5735 /* Layout.swift */; }; 54CFBFC5215CD09C006B5735 /* Core.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CFBFC4215CD09C006B5735 /* Core.swift */; }; 54DBA3DC262E938500D75969 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54DBA3DB262E938500D75969 /* Extensions.swift */; }; + 54DFB9762DC9DF8800006C84 /* SurfaceAppearance+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54DFB9752DC9DF8200006C84 /* SurfaceAppearance+.swift */; }; 54E3992727141F5100A8F9ED /* FloatingPanel.docc in Sources */ = {isa = PBXBuildFile; fileRef = 54E3992627141F5100A8F9ED /* FloatingPanel.docc */; }; 5D82A6B528D18464006A44BA /* libswiftCoreGraphics.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5D82A6B428D18461006A44BA /* libswiftCoreGraphics.tbd */; }; /* End PBXBuildFile section */ @@ -49,6 +60,11 @@ /* Begin PBXFileReference section */ 542753C522C49A6E00D17955 /* LayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutTests.swift; sourceTree = ""; }; 542753C722C49A8F00D17955 /* TestSupports.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSupports.swift; sourceTree = ""; }; + 543102572DB8AAB800A927EF /* View+floatingPanelConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+floatingPanelConfiguration.swift"; sourceTree = ""; }; + 543102592DB8AAB800A927EF /* View+floatingPanelSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+floatingPanelSurface.swift"; sourceTree = ""; }; + 543102B12DB8B4A300A927EF /* View+floatingPanelLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+floatingPanelLayout.swift"; sourceTree = ""; }; + 543102E92DB8F02D00A927EF /* View+floatingPanelBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+floatingPanelBehavior.swift"; sourceTree = ""; }; + 543103462DB9B4C900A927EF /* View+floatingPanelScrollTracking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+floatingPanelScrollTracking.swift"; sourceTree = ""; }; 54352E9521A51A2500CBCA08 /* Transitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transitioning.swift; sourceTree = ""; }; 54352E9721A521CA00CBCA08 /* PassthroughView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassthroughView.swift; sourceTree = ""; }; 5450EEE321646DF500135936 /* Behavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Behavior.swift; sourceTree = ""; }; @@ -64,7 +80,12 @@ 5469F4AF24B30E1500537F8A /* LayoutAnchoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutAnchoring.swift; sourceTree = ""; }; 5469F4B124B30F1100537F8A /* Position.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Position.swift; sourceTree = ""; }; 5469F4B324B30F3500537F8A /* LayoutProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutProperties.swift; sourceTree = ""; }; + 5471AF8D2CB53FC8001B7A64 /* View+floatingPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+floatingPanel.swift"; sourceTree = ""; }; + 5471AF8F2CB53FFC001B7A64 /* FloatingPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPanelView.swift; sourceTree = ""; }; + 5471AF912CB54628001B7A64 /* FloatingPanelProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPanelProxy.swift; sourceTree = ""; }; + 547B44902CC0E8AD00F7E68C /* FloatingPanelCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPanelCoordinator.swift; sourceTree = ""; }; 547F7A9B2A6E946000303905 /* GestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureTests.swift; sourceTree = ""; }; + 5484E7422DCF90A60058E962 /* View+floatingPanelState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+floatingPanelState.swift"; sourceTree = ""; }; 549C371E2361E15D007D8058 /* ExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionTests.swift; sourceTree = ""; }; 549E944422CF295D0050AECF /* StateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateTests.swift; sourceTree = ""; }; 54A6B6B022968B530077F348 /* CoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreTests.swift; sourceTree = ""; }; @@ -75,6 +96,7 @@ 54CFBFC2215CD045006B5735 /* Layout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Layout.swift; sourceTree = ""; }; 54CFBFC4215CD09C006B5735 /* Core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Core.swift; sourceTree = ""; }; 54DBA3DB262E938500D75969 /* Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; + 54DFB9752DC9DF8200006C84 /* SurfaceAppearance+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceAppearance+.swift"; sourceTree = ""; }; 54E3992627141F5100A8F9ED /* FloatingPanel.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = FloatingPanel.docc; sourceTree = ""; }; 5D82A6B428D18461006A44BA /* libswiftCoreGraphics.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libswiftCoreGraphics.tbd; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX12.3.sdk/usr/lib/swift/libswiftCoreGraphics.tbd; sourceTree = DEVELOPER_DIR; }; /* End PBXFileReference section */ @@ -121,6 +143,7 @@ 545DB9C32151169500CA77B8 /* Sources */ = { isa = PBXGroup; children = ( + 5471AF8C2CB53FB4001B7A64 /* SwiftUI */, 545DB9DF21511AC100CA77B8 /* Controller.swift */, 5469F4AD24B30D7E00537F8A /* State.swift */, 5469F4B124B30F1100537F8A /* Position.swift */, @@ -159,6 +182,24 @@ path = Tests; sourceTree = ""; }; + 5471AF8C2CB53FB4001B7A64 /* SwiftUI */ = { + isa = PBXGroup; + children = ( + 5471AF8F2CB53FFC001B7A64 /* FloatingPanelView.swift */, + 5471AF912CB54628001B7A64 /* FloatingPanelProxy.swift */, + 547B44902CC0E8AD00F7E68C /* FloatingPanelCoordinator.swift */, + 5471AF8D2CB53FC8001B7A64 /* View+floatingPanel.swift */, + 5484E7422DCF90A60058E962 /* View+floatingPanelState.swift */, + 543102B12DB8B4A300A927EF /* View+floatingPanelLayout.swift */, + 543102E92DB8F02D00A927EF /* View+floatingPanelBehavior.swift */, + 543102572DB8AAB800A927EF /* View+floatingPanelConfiguration.swift */, + 543102592DB8AAB800A927EF /* View+floatingPanelSurface.swift */, + 543103462DB9B4C900A927EF /* View+floatingPanelScrollTracking.swift */, + 54DFB9752DC9DF8200006C84 /* SurfaceAppearance+.swift */, + ); + path = SwiftUI; + sourceTree = ""; + }; 5D82A6B328D18460006A44BA /* Frameworks */ = { isa = PBXGroup; children = ( @@ -193,6 +234,7 @@ buildRules = ( ); dependencies = ( + 54A0CBA92CCB77390058BD47 /* PBXTargetDependency */, ); name = FloatingPanel; productName = FloatingModalController; @@ -245,6 +287,9 @@ Base, ); mainGroup = 545DB9B72151169500CA77B8; + packageReferences = ( + 54A0CBA72CCB772E0058BD47 /* XCLocalSwiftPackageReference "BuildTools" */, + ); productRefGroup = 545DB9C22151169500CA77B8 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -278,21 +323,32 @@ buildActionMask = 2147483647; files = ( 5469F4B224B30F1100537F8A /* Position.swift in Sources */, + 5484E7432DCF90AC0058E962 /* View+floatingPanelState.swift in Sources */, 5469F4B024B30E1500537F8A /* LayoutAnchoring.swift in Sources */, + 547B44912CC0E8B300F7E68C /* FloatingPanelCoordinator.swift in Sources */, + 543102EA2DB8F02E00A927EF /* View+floatingPanelBehavior.swift in Sources */, 54CDC5D3215B6D5A007D205C /* SurfaceView.swift in Sources */, 54CFBFC3215CD045006B5735 /* Layout.swift in Sources */, 5469F4B424B30F3500537F8A /* LayoutProperties.swift in Sources */, 54CDC5D5215B6D8D007D205C /* BackdropView.swift in Sources */, 54352E9821A521CA00CBCA08 /* PassthroughView.swift in Sources */, + 5471AF922CB5462C001B7A64 /* FloatingPanelProxy.swift in Sources */, 54CFBFC5215CD09C006B5735 /* Core.swift in Sources */, 54ABD7AF216CCFF7002E6C13 /* Logging.swift in Sources */, + 5471AF902CB53FFC001B7A64 /* FloatingPanelView.swift in Sources */, 545DB9E021511AC100CA77B8 /* Controller.swift in Sources */, + 5431025B2DB8AAB800A927EF /* View+floatingPanelSurface.swift in Sources */, + 543102B22DB8B4AC00A927EF /* View+floatingPanelLayout.swift in Sources */, + 5431025E2DB8AAB800A927EF /* View+floatingPanelConfiguration.swift in Sources */, 54DBA3DC262E938500D75969 /* Extensions.swift in Sources */, 5450EEE421646DF500135936 /* Behavior.swift in Sources */, 545DBA2B2152383100CA77B8 /* GrabberView.swift in Sources */, + 5471AF8E2CB53FC8001B7A64 /* View+floatingPanel.swift in Sources */, + 54DFB9762DC9DF8800006C84 /* SurfaceAppearance+.swift in Sources */, 54E3992727141F5100A8F9ED /* FloatingPanel.docc in Sources */, 54352E9621A51A2500CBCA08 /* Transitioning.swift in Sources */, 5469F4AE24B30D7E00537F8A /* State.swift in Sources */, + 543103472DB9B4D400A927EF /* View+floatingPanelScrollTracking.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -319,6 +375,10 @@ target = 545DB9C02151169500CA77B8 /* FloatingPanel */; targetProxy = 545DB9CC2151169500CA77B8 /* PBXContainerItemProxy */; }; + 54A0CBA92CCB77390058BD47 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = 54A0CBA82CCB77390058BD47 /* swift-format-plugin */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -460,9 +520,10 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_USER_SCRIPT_SANDBOXING = YES; INFOPLIST_FILE = Sources/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -492,9 +553,10 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_USER_SCRIPT_SANDBOXING = YES; INFOPLIST_FILE = Sources/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -517,7 +579,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Tests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -538,7 +600,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Tests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -629,9 +691,10 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_USER_SCRIPT_SANDBOXING = YES; INFOPLIST_FILE = Sources/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -656,7 +719,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Tests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -704,6 +767,21 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 54A0CBA72CCB772E0058BD47 /* XCLocalSwiftPackageReference "BuildTools" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = BuildTools; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 54A0CBA82CCB77390058BD47 /* swift-format-plugin */ = { + isa = XCSwiftPackageProductDependency; + package = 54A0CBA72CCB772E0058BD47 /* XCLocalSwiftPackageReference "BuildTools" */; + productName = "plugin:swift-format-plugin"; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 545DB9B82151169500CA77B8 /* Project object */; } diff --git a/FloatingPanel.xcworkspace/.xcodesamplecode.plist b/FloatingPanel.xcworkspace/.xcodesamplecode.plist new file mode 100644 index 000000000..5dd5da85f --- /dev/null +++ b/FloatingPanel.xcworkspace/.xcodesamplecode.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/FloatingPanel.xcworkspace/contents.xcworkspacedata b/FloatingPanel.xcworkspace/contents.xcworkspacedata index 1f19e899b..229e32121 100644 --- a/FloatingPanel.xcworkspace/contents.xcworkspacedata +++ b/FloatingPanel.xcworkspace/contents.xcworkspacedata @@ -11,10 +11,10 @@ location = "group:Examples" name = "Examples"> + location = "group:Maps/Maps.xcodeproj"> + location = "group:Maps-SwiftUI/Maps-SwiftUI.xcodeproj"> @@ -25,5 +25,8 @@ + + diff --git a/Package.swift b/Package.swift index 41f701e96..d6b6c5c65 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.0 +// swift-tools-version:5.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "FloatingPanel", platforms: [ - .iOS(.v11) + .iOS(.v12) ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. @@ -25,3 +25,4 @@ let package = Package( ], swiftLanguageVersions: [.version("5")] ) + diff --git a/README.md b/README.md index 941a403f9..ba6a7e8ed 100644 --- a/README.md +++ b/README.md @@ -1,716 +1,261 @@ -[![Swift 5](https://img.shields.io/badge/swift-5-orange.svg?style=flat)](https://swift.org/) -[![Platform](https://img.shields.io/cocoapods/p/FloatingPanel.svg)](https://cocoapods.org/pods/FloatingPanel) -[![Version](https://img.shields.io/cocoapods/v/FloatingPanel.svg)](https://cocoapods.org/pods/FloatingPanel) -![GitHub Workflow Status (with branch)](https://img.shields.io/github/actions/workflow/status/scenee/FloatingPanel/ci.yml?branch=master) - # FloatingPanel FloatingPanel is a simple and easy-to-use UI component designed for a user interface featured in Apple Maps, Shortcuts and Stocks app. The user interface displays related content and utilities alongside the main content. -Please see also [the API reference@SPI](https://swiftpackageindex.com/scenee/FloatingPanel/2.8.8/documentation/floatingpanel) for more details. +![Maps](Documentation/assets/maps.gif) +![Stocks](Documentation/assets/stocks.gif) -![Maps](https://github.com/SCENEE/FloatingPanel/blob/master/assets/maps.gif) -![Stocks](https://github.com/SCENEE/FloatingPanel/blob/master/assets/stocks.gif) +![Maps(Landscape)](Documentation/assets/maps-landscape.gif) -![Maps(Landscape)](https://github.com/SCENEE/FloatingPanel/blob/master/assets/maps-landscape.gif) +## Table of Contents - +
+ +Click here. + +- [Table of Contents](#table-of-contents) - [Features](#features) - [Requirements](#requirements) +- [Documentation](#documentation) - [Installation](#installation) - - [CocoaPods](#cocoapods) - [Swift Package Manager](#swift-package-manager) -- [Getting Started](#getting-started) - - [Add a floating panel as a child view controller](#add-a-floating-panel-as-a-child-view-controller) - - [Present a floating panel as a modality](#present-a-floating-panel-as-a-modality) -- [View hierarchy](#view-hierarchy) -- [Usage](#usage) - - [Show/Hide a floating panel in a view with your view hierarchy](#showhide-a-floating-panel-in-a-view-with-your-view-hierarchy) - - [Scale the content view when the surface position changes](#scale-the-content-view-when-the-surface-position-changes) - - [Customize the layout with `FloatingPanelLayout` protocol](#customize-the-layout-with-floatingpanellayout-protocol) - - [Change the initial layout](#change-the-initial-layout) - - [Update your panel layout](#update-your-panel-layout) - - [Support your landscape layout](#support-your-landscape-layout) - - [Use the intrinsic size of a content in your panel layout](#use-the-intrinsic-size-of-a-content-in-your-panel-layout) - - [Specify an anchor for each state by an inset of the `FloatingPanelController.view` frame](#specify-an-anchor-for-each-state-by-an-inset-of-the-floatingpanelcontrollerview-frame) - - [Change the backdrop alpha](#change-the-backdrop-alpha) - - [Using custome panel states](#using-custome-panel-states) - - [Customize the behavior with `FloatingPanelBehavior` protocol](#customize-the-behavior-with-floatingpanelbehavior-protocol) - - [Modify your floating panel's interaction](#modify-your-floating-panels-interaction) - - [Activate the rubber-band effect on panel edges](#activate-the-rubber-band-effect-on-panel-edges) - - [Manage the projection of a pan gesture momentum](#manage-the-projection-of-a-pan-gesture-momentum) - - [Specify the panel move's boundary](#specify-the-panel-moves-boundary) - - [Customize the surface design](#customize-the-surface-design) - - [Modify your surface appearance](#modify-your-surface-appearance) - - [Use a custom grabber handle](#use-a-custom-grabber-handle) - - [Customize layout of the grabber handle](#customize-layout-of-the-grabber-handle) - - [Customize content padding from surface edges](#customize-content-padding-from-surface-edges) - - [Customize margins of the surface edges](#customize-margins-of-the-surface-edges) - - [Customize gestures](#customize-gestures) - - [Suppress the panel interaction](#suppress-the-panel-interaction) - - [Add tap gestures to the surface view](#add-tap-gestures-to-the-surface-view) - - [Interrupt the delegate methods of `FloatingPanelController.panGestureRecognizer`](#interrupt-the-delegate-methods-of-floatingpanelcontrollerpangesturerecognizer) - - [Create an additional floating panel for a detail](#create-an-additional-floating-panel-for-a-detail) - - [Move a position with an animation](#move-a-position-with-an-animation) - - [Work your contents together with a floating panel behavior](#work-your-contents-together-with-a-floating-panel-behavior) - - [Enabling the tap-to-dismiss action of the backdrop view](#enabling-the-tap-to-dismiss-action-of-the-backdrop-view) - - [Allow to scroll content of the tracking scroll view in addition to the most expanded state](#allow-to-scroll-content-of-the-tracking-scroll-view-in-addition-to-the-most-expanded-state) -- [Notes](#notes) - - ['Show' or 'Show Detail' Segues from `FloatingPanelController`'s content view controller](#show-or-show-detail-segues-from-floatingpanelcontrollers-content-view-controller) - - [UISearchController issue](#uisearchcontroller-issue) + - [Using Xcode](#using-xcode) + - [Using Package.swift](#using-packageswift) + - [CocoaPods](#cocoapods) +- [Getting Started with SwiftUI](#getting-started-with-swiftui) + - [Adding a floating panel within a view](#adding-a-floating-panel-within-a-view) + - [Presenting a floating panel modally within a view](#presenting-a-floating-panel-modally-within-a-view) + - [Displaying multiple panels](#displaying-multiple-panels) + - [Next step](#next-step) +- [Getting Started with UIKit](#getting-started-with-uikit) + - [Adding a floating panel as a child view controller](#adding-a-floating-panel-as-a-child-view-controller) + - [Presenting a floating panel modally](#presenting-a-floating-panel-modally) + - [Next step](#next-step-1) - [Maintainer](#maintainer) - [License](#license) - +
## Features -- [x] Simple container view controller -- [x] Fluid behavior using numeric springing -- [x] Scroll view tracking -- [x] Removal interaction -- [x] Multi panel support -- [x] Modal presentation -- [x] Support for 4 positions (top, left, bottom, right) -- [x] 1 or more magnetic anchors(full, half, tip and more) -- [x] Layout support for all trait environments(i.e. Landscape orientation) -- [x] Common UI elements: surface, backdrop and grabber handle -- [x] Free from common Auto Layout and gesture handling issues -- [x] Compatible with Objective-C +- Simple container view controller +- Fluid behavior using numeric springing +- Scroll view tracking +- Removal interaction +- Multi panel support +- Modal presentation +- Support for 4 positions (top, left, bottom, right) +- 1 or more magnetic anchors(full, half, tip and more) +- Layout support for all trait environments(i.e. Landscape orientation) +- Common UI elements: surface, backdrop and grabber handle +- Free from common Auto Layout and gesture handling issues +- Compatible with Objective-C +- SwiftUI API support Examples can be found here: -- [Examples/Maps](https://github.com/SCENEE/FloatingPanel/tree/master/Examples/Maps) like Apple Maps.app. -- [Examples/Stocks](https://github.com/SCENEE/FloatingPanel/tree/master/Examples/Stocks) like Apple Stocks.app. -- [Examples/Samples](https://github.com/SCENEE/FloatingPanel/tree/master/Examples/Samples) -- [Examples/SamplesObjC](https://github.com/SCENEE/FloatingPanel/tree/master/Examples/SamplesObjC) +- [Maps](Examples/Maps) like Apple Maps.app. +- [Maps-SwiftUI](Examples/Maps-SwiftUI) like Apple Maps.app using SwiftUI. +- [Stocks](Examples/Stocks) like Apple Stocks.app. +- [Samples](Examples/Samples) +- [SamplesObjC](Examples/SamplesObjC) +- [SamplesSwiftUI](Examples/SamplesSwiftUI) ## Requirements -FloatingPanel is written in Swift 5.0+ and compatible with iOS 11.0+. +FloatingPanel is written in Swift 5.0+ and compatible with iOS 12.0+. -## Installation +## Documentation -### CocoaPods +- [API reference on Swift Package Index](https://swiftpackageindex.com/scenee/FloatingPanel/3.2.1/documentation/floatingpanel) +- [FloatingPanel SwiftUI API Guide](/Documentation/FloatingPanel%20SwiftUI%20API%20Guide.md) +- [FloatingPanel API Guide](/Documentation/FloatingPanel%20API%20Guide.md) +- [FloatingPanel 2.0 Migration Guide](/Documentation/FloatingPanel%202.0%20Migration%20Guide.md) -FloatingPanel is available through [CocoaPods](https://cocoapods.org). To install -it, simply add the following line to your Podfile: - -```ruby -pod 'FloatingPanel' -``` +## Installation ### Swift Package Manager -Follow [this doc](https://developer.apple.com/documentation/swift_packages/adding_package_dependencies_to_your_app). - -## Getting Started - -### Add a floating panel as a child view controller - -```swift -import UIKit -import FloatingPanel - -class ViewController: UIViewController, FloatingPanelControllerDelegate { - var fpc: FloatingPanelController! - - override func viewDidLoad() { - super.viewDidLoad() - // Initialize a `FloatingPanelController` object. - fpc = FloatingPanelController() - - // Assign self as the delegate of the controller. - fpc.delegate = self // Optional - - // Set a content view controller. - let contentVC = ContentViewController() - fpc.set(contentViewController: contentVC) - - // Track a scroll view(or the siblings) in the content view controller. - fpc.track(scrollView: contentVC.tableView) - - // Add and show the views managed by the `FloatingPanelController` object to self.view. - fpc.addPanel(toParent: self) - } -} -``` - -### Present a floating panel as a modality - -```swift -let fpc = FloatingPanelController() -let contentVC = ... -fpc.set(contentViewController: contentVC) - -fpc.isRemovalInteractionEnabled = true // Optional: Let it removable by a swipe-down - -self.present(fpc, animated: true, completion: nil) -``` - -You can show a floating panel over UINavigationController from the container view controllers as a modality of `.overCurrentContext` style. - -> [!NOTE] -> FloatingPanelController has the custom presentation controller. If you would like to customize the presentation/dismissal, please see [Transitioning](https://github.com/SCENEE/FloatingPanel/blob/master/Sources/Transitioning.swift). - -## View hierarchy - -`FloatingPanelController` manages the views as the following view hierarchy. - -``` -FloatingPanelController.view (FloatingPanelPassThroughView) - ├─ .backdropView (FloatingPanelBackdropView) - └─ .surfaceView (FloatingPanelSurfaceView) - ├─ .containerView (UIView) - │ └─ .contentView (FloatingPanelController.contentViewController.view) - └─ .grabber (FloatingPanelGrabberView) -``` - -## Usage +#### Using Xcode -### Show/Hide a floating panel in a view with your view hierarchy +Just follow [this documentation](https://developer.apple.com/documentation/swift_packages/adding_package_dependencies_to_your_app). -If you need more control over showing and hiding the floating panel, you can forgo the `addPanel` and `removePanelFromParent` methods. These methods are a convenience wrapper for **FloatingPanel**'s `show` and `hide` methods along with some required setup. +#### Using Package.swift -There are two ways to work with the `FloatingPanelController`: -1. Add it to the hierarchy once and then call `show` and `hide` methods to make it appear/disappear. -2. Add it to the hierarchy when needed and remove afterwards. - -The following example shows how to add the controller to your `UIViewController` and how to remove it. Make sure that you never add the same `FloatingPanelController` to the hierarchy before removing it. - -**NOTE**: `self.` prefix is not required, nor recommended. It's used here to make it clearer where do the functions used come from. `self` is an instance of a custom UIViewController in your code. +In your Package.swift Swift Package Manager manifest, add the following dependency to your dependencies argument: ```swift -// Add the floating panel view to the controller's view on top of other views. -self.view.addSubview(fpc.view) - -// REQUIRED. It makes the floating panel view have the same size as the controller's view. -fpc.view.frame = self.view.bounds - -// In addition, Auto Layout constraints are highly recommended. -// Constraint the fpc.view to all four edges of your controller's view. -// It makes the layout more robust on trait collection change. -fpc.view.translatesAutoresizingMaskIntoConstraints = false -NSLayoutConstraint.activate([ - fpc.view.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 0.0), - fpc.view.leftAnchor.constraint(equalTo: self.view.leftAnchor, constant: 0.0), - fpc.view.rightAnchor.constraint(equalTo: self.view.rightAnchor, constant: 0.0), - fpc.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0.0), -]) - -// Add the floating panel controller to the controller hierarchy. -self.addChild(fpc) - -// Show the floating panel at the initial position defined in your `FloatingPanelLayout` object. -fpc.show(animated: true) { - // Inform the floating panel controller that the transition to the controller hierarchy has completed. - fpc.didMove(toParent: self) -} +.package(url: "https://github.com/scenee/FloatingPanel", from: "3.2.1"), ``` -After you add the `FloatingPanelController` as seen above, you can call `fpc.show(animated: true) { }` to show the panel and `fpc.hide(animated: true) { }` to hide it. - -To remove the `FloatingPanelController` from the hierarchy, follow the example below. +Add Numerics as a dependency for your target: ```swift -// Inform the panel controller that it will be removed from the hierarchy. -fpc.willMove(toParent: nil) - -// Hide the floating panel. -fpc.hide(animated: true) { - // Remove the floating panel view from your controller's view. - fpc.view.removeFromSuperview() - // Remove the floating panel controller from the controller hierarchy. - fpc.removeFromParent() -} +.target(name: "MyTarget", dependencies: [ + .product(name: "FloatingPanel", package: "FloatingPanel"), + "AnotherModule" +]), ``` -### Scale the content view when the surface position changes - -Specify the `contentMode` to `.fitToBounds` if the surface height fits the bounds of `FloatingPanelController.view` when the surface position changes - -```swift -fpc.contentMode = .fitToBounds -``` +And then add `import FloatingPanel` in your source code. -Otherwise, `FloatingPanelController` fixes the content by the height of the top most position. - -> [!NOTE] -> In `.fitToBounds` mode, the surface height changes as following a user interaction so that you have a responsibility to configure Auto Layout constrains not to break the layout of a content view by the elastic surface height. - -### Customize the layout with `FloatingPanelLayout` protocol - -#### Change the initial layout - -```swift -class ViewController: UIViewController, FloatingPanelControllerDelegate { - ... { - fpc = FloatingPanelController(delegate: self) - fpc.layout = MyFloatingPanelLayout() - } -} - -class MyFloatingPanelLayout: FloatingPanelLayout { - let position: FloatingPanelPosition = .bottom - let initialState: FloatingPanelState = .tip - let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [ - .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea), - .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .safeArea), - .tip: FloatingPanelLayoutAnchor(absoluteInset: 44.0, edge: .bottom, referenceGuide: .safeArea), - ] -} -``` - -### Update your panel layout - -There are 2 ways to update the panel layout. - -1. Manually set `FloatingPanelController.layout` to the new layout object directly. - -```swift -fpc.layout = MyPanelLayout() -fpc.invalidateLayout() // If needed -``` - -Note: If you already set the `delegate` property of your `FloatingPanelController` instance, `invalidateLayout()` overrides the layout object of `FloatingPanelController` with one returned by the delegate object. - -2. Returns an appropriate layout object in one of 2 `floatingPanel(_:layoutFor:)` delegates. - -```swift -class ViewController: UIViewController, FloatingPanelControllerDelegate { - ... - func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout { - return MyFloatingPanelLayout() - } - - // OR - func floatingPanel(_ vc: FloatingPanelController, layoutFor size: CGSize) -> FloatingPanelLayout { - return MyFloatingPanelLayout() - } -} -``` - -#### Support your landscape layout +### CocoaPods -```swift -class ViewController: UIViewController, FloatingPanelControllerDelegate { - ... - func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout { - return (newCollection.verticalSizeClass == .compact) ? LandscapePanelLayout() : FloatingPanelBottomLayout() - } -} +FloatingPanel is available through [CocoaPods](https://cocoapods.org). To install +it, simply add the following line to your Podfile: -class LandscapePanelLayout: FloatingPanelLayout { - let position: FloatingPanelPosition = .bottom - let initialState: FloatingPanelState = .tip - let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [ - .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea), - .tip: FloatingPanelLayoutAnchor(absoluteInset: 69.0, edge: .bottom, referenceGuide: .safeArea), - ] - - func prepareLayout(surfaceView: UIView, in view: UIView) -> [NSLayoutConstraint] { - return [ - surfaceView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor, constant: 8.0), - surfaceView.widthAnchor.constraint(equalToConstant: 291), - ] - } -} +```ruby +pod 'FloatingPanel' ``` -#### Use the intrinsic size of a content in your panel layout +## Getting Started with SwiftUI -1. Lay out your content View with the intrinsic height size. For example, see "Detail View Controller scene"/"Intrinsic View Controller scene" of [Main.storyboard](https://github.com/SCENEE/FloatingPanel/blob/master/Examples/Samples/Sources/Base.lproj/Main.storyboard). The 'Stack View.bottom' constraint determines the intrinsic height. -2. Specify layout anchors using `FloatingPanelIntrinsicLayoutAnchor`. +### Adding a floating panel within a view ```swift -class IntrinsicPanelLayout: FloatingPanelLayout { - let position: FloatingPanelPosition = .bottom - let initialState: FloatingPanelState = .full - let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [ - .full: FloatingPanelIntrinsicLayoutAnchor(absoluteOffset: 0, referenceGuide: .safeArea), - .half: FloatingPanelIntrinsicLayoutAnchor(fractionalOffset: 0.5, referenceGuide: .safeArea), - ] - ... -} -``` - -> [!WARNING] -> `FloatingPanelIntrinsicLayout` is deprecated on v1. +@State private var layout = MyFloatingPanelLayout() +@State private var state: FloatingPanelState? -#### Specify an anchor for each state by an inset of the `FloatingPanelController.view` frame - -Use `.superview` reference guide in your anchors. - -```swift -class MyFullScreenLayout: FloatingPanelLayout { - ... - let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [ - .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .superview), - .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .superview), - .tip: FloatingPanelLayoutAnchor(absoluteInset: 44.0, edge: .bottom, referenceGuide: .superview), - ] +var view: some View { + MainView() + .floatingPanel { proxy in + ScrollView { + VStack(spacing: 20) { + ForEach(items) { item in + ItemRow(item) + } + } + .padding() + } + .floatingPanelScrollTracking(proxy: proxy) + } + .floatingPanelState($state) + .floatingPanelLayout(layout) + .floatingPanelBehavior(MyCustomBehavior()) + .floatingPanelSurfaceAppearance(.transparent) } ``` -> [!WARNING] -> `FloatingPanelFullScreenLayout` is deprecated on v1. - -#### Change the backdrop alpha +### Presenting a floating panel modally within a view -You can change the backdrop alpha by `FloatingPanelLayout.backdropAlpha(for:)` for each state(`.full`, `.half` and `.tip`). - -For instance, if a panel seems like the backdrop view isn't there on `.half` state, it's time to implement the backdropAlpha API and return a value for the state as below. +Please define a custom coordinator object to present a floating panel as a modality. ```swift -class MyPanelLayout: FloatingPanelLayout { - func backdropAlpha(for state: FloatingPanelState) -> CGFloat { - switch state { - case .full, .half: return 0.3 - default: return 0.0 +struct HomeView: View { + var view: some View { + MainView() + .floatingPanel( + coordinator: MyPanelCoordinator.self + ) { proxy in + ... } - } + } } -``` - -#### Using custome panel states -You're able to define custom panel states and use them as the following example. - -```swift -extension FloatingPanelState { - static let lastQuart: FloatingPanelState = FloatingPanelState(rawValue: "lastQuart", order: 750) - static let firstQuart: FloatingPanelState = FloatingPanelState(rawValue: "firstQuart", order: 250) -} - -class FloatingPanelLayoutWithCustomState: FloatingPanelBottomLayout { - override var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] { - return [ - .full: FloatingPanelLayoutAnchor(absoluteInset: 0.0, edge: .top, referenceGuide: .safeArea), - .lastQuart: FloatingPanelLayoutAnchor(fractionalInset: 0.75, edge: .bottom, referenceGuide: .safeArea), - .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .safeArea), - .firstQuart: FloatingPanelLayoutAnchor(fractionalInset: 0.25, edge: .bottom, referenceGuide: .safeArea), - .tip: FloatingPanelLayoutAnchor(absoluteInset: 20.0, edge: .bottom, referenceGuide: .safeArea), - ] - } -} -``` - -### Customize the behavior with `FloatingPanelBehavior` protocol - -#### Modify your floating panel's interaction - -```swift -class ViewController: UIViewController, FloatingPanelControllerDelegate { +class MyPanelCoordinator: FloatingPanelCoordinator { ... - func viewDidLoad() { - ... - fpc.behavior = CustomPanelBehavior() - } -} - -class CustomPanelBehavior: FloatingPanelBehavior { - let springDecelerationRate = UIScrollView.DecelerationRate.fast.rawValue + 0.02 - let springResponseTime = 0.4 - func shouldProjectMomentum(_ fpc: FloatingPanelController, to proposedState: FloatingPanelState) -> Bool { - return true - } -} -``` - -> [!WARNING] -> `floatingPanel(_ vc:behaviorFor:)` is deprecated on v1. - -#### Activate the rubber-band effect on panel edges - -```swift -class MyPanelBehavior: FloatingPanelBehavior { - ... - func allowsRubberBanding(for edge: UIRectEdge) -> Bool { - return true + func setupFloatingPanel( + mainHostingController: UIHostingController
, + contentHostingController: UIHostingController + ) where Main: View, Content: View { + // Set the delegate object + controller.delegate = delegate + + // Set up the content + contentHostingController.view.backgroundColor = .clear + controller.set(contentViewController: contentHostingController) + + /* =============== HERE ==================== */ + // NOTE: + // Present the floating panel on the next run loop cycle + // to ensure proper view hierarchy setup. + Task { @MainActor in + mainHostingController.present(controller, animated: false) + } } -} -``` - -#### Manage the projection of a pan gesture momentum - -This allows full projectional panel behavior. For example, a user can swipe up a panel from tip to full nearby the tip position. - -```swift -class MyPanelBehavior: FloatingPanelBehavior { ... - func shouldProjectMomentum(_ fpc: FloatingPanelController, to proposedState: FloatingPanelPosition) -> Bool { - return true - } } ``` -### Specify the panel move's boundary - -`FloatingPanelController.surfaceLocation` in `floatingPanelDidMove(_:)` delegate method behaves like `UIScrollView.contentOffset` in `scrollViewDidScroll(_:)`. -As a result, you can specify the boundary of a panel move as below. +### Displaying multiple panels + +Multiple floating panels can be displayed in the same view hierarchy. To customize the layout and behavior for each Floating Panel individually, place modifiers directly below each panel. ```swift -func floatingPanelDidMove(_ vc: FloatingPanelController) { - if vc.isAttracting == false { - let loc = vc.surfaceLocation - let minY = vc.surfaceLocation(for: .full).y - 6.0 - let maxY = vc.surfaceLocation(for: .tip).y + 6.0 - vc.surfaceLocation = CGPoint(x: loc.x, y: min(max(loc.y, minY), maxY)) +Color.orange + .ignoresSafeArea() + .floatingPanel( + coordinator: MyPanelCoordinator.self + ) { proxy in + ContentView(proxy: proxy) } -} -``` - -> [!WARNING] -> `{top,bottom}InteractionBuffer` property is removed from `FloatingPanelLayout` since v2. - -### Customize the surface design - -#### Modify your surface appearance - -```swift -// Create a new appearance. -let appearance = SurfaceAppearance() - -// Define shadows -let shadow = SurfaceAppearance.Shadow() -shadow.color = UIColor.black -shadow.offset = CGSize(width: 0, height: 16) -shadow.radius = 16 -shadow.spread = 8 -appearance.shadows = [shadow] - -// Define corner radius and background color -appearance.cornerRadius = 8.0 -appearance.backgroundColor = .clear - -// Set the new appearance -fpc.surfaceView.appearance = appearance -```` - -#### Use a custom grabber handle - -```swift -let myGrabberHandleView = MyGrabberHandleView() -fpc.surfaceView.grabberHandle.isHidden = true -fpc.surfaceView.addSubview(myGrabberHandleView) -``` - -#### Customize layout of the grabber handle - -```swift -fpc.surfaceView.grabberHandlePadding = 10.0 -fpc.surfaceView.grabberHandleSize = .init(width: 44.0, height: 12.0) -``` - -> [!NOTE] -> `grabberHandleSize` width and height are reversed in the left/right position. - -#### Customize content padding from surface edges - -```swift -fpc.surfaceView.contentPadding = .init(top: 20, left: 20, bottom: 20, right: 20) -``` - -#### Customize margins of the surface edges - -```swift -fpc.surfaceView.containerMargins = .init(top: 20.0, left: 16.0, bottom: 16.0, right: 16.0) -``` - -The feature can be used for these 2 kind panels - -* Facebook/Slack-like panel whose surface top edge is separated from the grabber handle. -* iOS native panel to display AirPods information, for example. - -### Customize gestures - -#### Suppress the panel interaction - -You can disable the pan gesture recognizer directly - -```swift -fpc.panGestureRecognizer.isEnabled = false -``` - -Or use this `FloatingPanelControllerDelegate` method. - -```swift -func floatingPanelShouldBeginDragging(_ vc: FloatingPanelController) -> Bool { - return aCondition ? false : true -} -``` - -#### Add tap gestures to the surface view - -```swift -override func viewDidLoad() { - ... - let surfaceTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleSurface(tapGesture:))) - fpc.surfaceView.addGestureRecognizer(surfaceTapGesture) - surfaceTapGesture.isEnabled = (fpc.position == .tip) -} - -// Enable `surfaceTapGesture` only at `tip` state -func floatingPanelDidChangeState(_ vc: FloatingPanelController) { - surfaceTapGesture.isEnabled = (vc.position == .tip) -} -``` - -#### Interrupt the delegate methods of `FloatingPanelController.panGestureRecognizer` - -If you are set `FloatingPanelController.panGestureRecognizer.delegateProxy` to an object adopting `UIGestureRecognizerDelegate`, it overrides delegate methods of the pan gesture recognizer. - -```swift -class MyGestureRecognizerDelegate: UIGestureRecognizerDelegate { - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool { - return false - } -} - -class ViewController: UIViewController { - let myGestureDelegate = MyGestureRecognizerDelegate() - - func setUpFpc() { - .... - fpc.panGestureRecognizer.delegateProxy = myGestureDelegate + .floatingPanelSurfaceAppearance(.transparent()) + .floatingPanel( + coordinator: MyPanelCoordinator.self + ) { proxy in + ContentView(proxy: proxy) } + .floatingPanelSurfaceAppearance(.transparent(cornerRadius: 24)) ``` -### Create an additional floating panel for a detail - -```swift -override func viewDidLoad() { - // Setup Search panel - self.searchPanelVC = FloatingPanelController() - - let searchVC = SearchViewController() - self.searchPanelVC.set(contentViewController: searchVC) - self.searchPanelVC.track(scrollView: contentVC.tableView) - - self.searchPanelVC.addPanel(toParent: self) - - // Setup Detail panel - self.detailPanelVC = FloatingPanelController() - - let contentVC = ContentViewController() - self.detailPanelVC.set(contentViewController: contentVC) - self.detailPanelVC.track(scrollView: contentVC.scrollView) - - self.detailPanelVC.addPanel(toParent: self) -} -``` - -### Move a position with an animation +### Next step -In the following example, I move a floating panel to full or half position while opening or closing a search bar like Apple Maps. +For more details, see the [FloatingPanel SwiftUI API Guide](/Documentation/FloatingPanel%20SwiftUI%20API%20Guide.md) -```swift -func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - ... - fpc.move(to: .half, animated: true) -} - -func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { - ... - fpc.move(to: .full, animated: true) -} -``` +## Getting Started with UIKit -You can also use a view animation to move a panel. +### Adding a floating panel as a child view controller ```swift -UIView.animate(withDuration: 0.25) { - self.fpc.move(to: .half, animated: false) -} -``` - -### Work your contents together with a floating panel behavior +import UIKit +import FloatingPanel -```swift class ViewController: UIViewController, FloatingPanelControllerDelegate { - ... - func floatingPanelWillBeginDragging(_ vc: FloatingPanelController) { - if vc.position == .full { - searchVC.searchBar.showsCancelButton = false - searchVC.searchBar.resignFirstResponder() - } - } - - func floatingPanelWillEndDragging(_ vc: FloatingPanelController, withVelocity velocity: CGPoint, targetState: UnsafeMutablePointer) { - if targetState.pointee != .full { - searchVC.hideHeader() - } - } -} -``` - -### Enabling the tap-to-dismiss action of the backdrop view + var fpc: FloatingPanelController! -The tap-to-dismiss action is disabled by default. So it needs to be enabled as below. + override func viewDidLoad() { + super.viewDidLoad() + // Initialize a `FloatingPanelController` object. + fpc = FloatingPanelController() -```swift -fpc.backdropView.dismissalTapGestureRecognizer.isEnabled = true -``` + // Assign self as the delegate of the controller. + fpc.delegate = self // Optional -### Allow to scroll content of the tracking scroll view in addition to the most expanded state + // Set a content view controller. + let contentVC = ContentViewController() + fpc.set(contentViewController: contentVC) -Just define conditions to allow content scrolling in `floatingPanel(:_:shouldAllowToScroll:in)` delegate method. If the returned value is true, the scroll content scrolls when its scroll position is not at the top of the content. + // Track a scroll view(or the siblings) in the content view controller. + fpc.track(scrollView: contentVC.tableView) -```swift -class MyViewController: FloatingPanelControllerDelegate { - ... - - func floatingPanel( - _ fpc: FloatingPanelController, - shouldAllowToScroll trackingScrollView: UIScrollView, - in state: FloatingPanelState - ) -> Bool { - return state == .full || state == .half + // Add and show the views managed by the `FloatingPanelController` object to self.view. + fpc.addPanel(toParent: self) } } ``` -## Notes - -### 'Show' or 'Show Detail' Segues from `FloatingPanelController`'s content view controller - -'Show' or 'Show Detail' segues from a content view controller will be managed by a view controller(hereinafter called 'master VC') adding a floating panel. Because a floating panel is just a subview of the master VC(except for modality). - -`FloatingPanelController` has no way to manage a stack of view controllers like `UINavigationController`. If so, it would be so complicated and the interface will become `UINavigationController`. This component should not have the responsibility to manage the stack. - -By the way, a content view controller can present a view controller modally with `present(_:animated:completion:)` or 'Present Modally' segue. - -However, sometimes you want to show a destination view controller of 'Show' or 'Show Detail' segue with another floating panel. It's possible to override `show(_:sender)` of the master VC! - -Here is an example. +### Presenting a floating panel modally ```swift -class ViewController: UIViewController { - var fpc: FloatingPanelController! - var secondFpc: FloatingPanelController! - - ... - override func show(_ vc: UIViewController, sender: Any?) { - secondFpc = FloatingPanelController() +let fpc = FloatingPanelController() +let contentVC = ... +fpc.set(contentViewController: contentVC) - secondFpc.set(contentViewController: vc) +fpc.isRemovalInteractionEnabled = true // Optional: Let it removable by a swipe-down - secondFpc.addPanel(toParent: self) - } -} +self.present(fpc, animated: true, completion: nil) ``` -A `FloatingPanelController` object proxies an action for `show(_:sender)` to the master VC. That's why the master VC can handle a destination view controller of a 'Show' or 'Show Detail' segue and you can hook `show(_:sender)` to show a secondary floating panel set the destination view controller to the content. - -It's a great way to decouple between a floating panel and the content VC. +You can show a floating panel over UINavigationController from the container view controllers as a modality of `.overCurrentContext` style. -### UISearchController issue +> [!NOTE] +> FloatingPanelController has the custom presentation controller. If you would like to customize the presentation/dismissal, please see [Transitioning](Sources/Transitioning.swift). -`UISearchController` isn't able to be used with `FloatingPanelController` by the system design. +### Next step -Because `UISearchController` automatically presents itself modally when a user interacts with the search bar, and then it swaps the superview of the search bar to the view managed by itself while it displays. As a result, `FloatingPanelController` can't control the search bar when it's active, as you can see from [the screen shot](https://github.com/SCENEE/FloatingPanel/issues/248#issuecomment-521263831). +For more details, see the [FloatingPanel API Guide](/Documentation/FloatingPanel%20API%20Guide.md). ## Maintainer @@ -719,3 +264,6 @@ Shin Yamamoto | [@scenee](https://twitter.com/scenee) ## License FloatingPanel is available under the MIT license. See the LICENSE file for more info. + +[![Star History Chart](https://api.star-history.com/svg?repos=scenee/FloatingPanel&type=date&legend=top-left)](https://www.star-history.com/#scenee/FloatingPanel&type=date&legend=top-left) + diff --git a/Sources/Controller.swift b/Sources/Controller.swift index af5af86f5..8080dcd83 100644 --- a/Sources/Controller.swift +++ b/Sources/Controller.swift @@ -15,18 +15,30 @@ import os.log @objc(floatingPanel:layoutForSize:) optional func floatingPanel(_ fpc: FloatingPanelController, layoutFor size: CGSize) -> FloatingPanelLayout - /// Returns a UIViewPropertyAnimator object to add/present the panel to a position. + /// Returns a UIViewPropertyAnimator object to add/present the panel to a state anchor. /// /// Default is the spring animation with 0.25 secs. @objc(floatingPanel:animatorForPresentingToState:) optional func floatingPanel(_ fpc: FloatingPanelController, animatorForPresentingTo state: FloatingPanelState) -> UIViewPropertyAnimator - /// Returns a UIViewPropertyAnimator object to remove/dismiss a panel from a position. + /// Returns a UIViewPropertyAnimator object to remove/dismiss a panel. /// /// Default is the spring animator with 0.25 secs. @objc(floatingPanel:animatorForDismissingWithVelocity:) optional func floatingPanel(_ fpc: FloatingPanelController, animatorForDismissingWith velocity: CGVector) -> UIViewPropertyAnimator + /// Returns a UIViewPropertyAnimator object to move a panel to a state anchor. + /// + /// When this method is not implemented, FloatingPanelController uses its own custom spring animation when + /// ``FloatingPanelController/move(to:animated:completion:)`` is called. The custom animation is the same as + /// attracting animation after a user moves a panel by finger. + /// If you implement this method, the returned UIViewPropertyAnimator will be used when + /// ``FloatingPanelController/move(to:animated:completion:)`` is called if the current state or the target state + /// (`to` argument) are not `.hidden`. + @objc(floatingPanel:animatorForMovingTo:) optional + func floatingPanel(_ fpc: FloatingPanelController, animatorForMovingTo state: FloatingPanelState) -> UIViewPropertyAnimator + + /// Called when a panel has changed to a new state. /// /// This can be called inside an animation block for presenting, dismissing a panel or moving a panel with your @@ -595,7 +607,21 @@ open class FloatingPanelController: UIViewController { /// - completion: The block to execute after the view controller has finished moving. This block has no return value and takes no parameters. You may specify nil for this parameter. @objc(moveToState:animated:completion:) public func move(to: FloatingPanelState, animated: Bool, completion: (() -> Void)? = nil) { - floatingPanel.move(to: to, animated: animated, completion: completion) + floatingPanel.move( + to: to, + animated: animated, + moveAnimator: animatorForMoving(to: to), + completion: completion + ) + } + + func moveForSwiftUI(to: FloatingPanelState, animated: Bool, completion: (() -> Void)? = nil) { + floatingPanel.move( + to: to, + animated: animated, + moveAnimator: animatorForMoving(to: to) ?? makeDefaultAnimator(), + completion: completion + ) } /// Sets the view controller responsible for the content portion of a panel. @@ -719,24 +745,39 @@ extension FloatingPanelController { if let animator = delegate?.floatingPanel?(self, animatorForPresentingTo: to) { return animator } - let timingParameters = UISpringTimingParameters(decelerationRate: UIScrollView.DecelerationRate.fast.rawValue, - frequencyResponse: 0.25) - return UIViewPropertyAnimator(duration: 0.0, - timingParameters: timingParameters) + return makeDefaultAnimator() } func animatorForDismissing(with velocity: CGVector) -> UIViewPropertyAnimator { if let animator = delegate?.floatingPanel?(self, animatorForDismissingWith: velocity) { return animator } - let timingParameters = UISpringTimingParameters(decelerationRate: UIScrollView.DecelerationRate.fast.rawValue, - frequencyResponse: 0.25, - initialVelocity: velocity) - return UIViewPropertyAnimator(duration: 0.0, - timingParameters: timingParameters) + return makeDefaultAnimator(initialVelocity: velocity) + } + + func animatorForMoving(to: FloatingPanelState) -> UIViewPropertyAnimator? { + return delegate?.floatingPanel?(self, animatorForMovingTo: to) } } + +// MARK: - Animation + +extension FloatingPanelController { + public func makeDefaultAnimator(initialVelocity: CGVector = .zero) -> UIViewPropertyAnimator { + let timingParameters = UISpringTimingParameters( + decelerationRate: UIScrollView.DecelerationRate.fast.rawValue, + frequencyResponse: 0.25, + initialVelocity: initialVelocity + ) + return UIViewPropertyAnimator( + duration: 0.0, + timingParameters: timingParameters + ) + } +} + + // MARK: - Swizzling private var originalDismissImp: IMP? diff --git a/Sources/Core.swift b/Sources/Core.swift index 28ebe98c4..25b183367 100644 --- a/Sources/Core.swift +++ b/Sources/Core.swift @@ -1,5 +1,8 @@ // Copyright 2018-Present Shin Yamamoto. All rights reserved. MIT license. +#if canImport(Combine) +import Combine +#endif import UIKit import WebKit import os.log @@ -91,7 +94,14 @@ class Core: NSObject, UIGestureRecognizerDelegate { } } - let panGestureRecognizer: FloatingPanelPanGestureRecognizer + @available(iOS 13.0, *) + private(set) var statePublisher: CurrentValueSubject? { + get { _statePublisher as? CurrentValueSubject } + set { _statePublisher = newValue } + } + private var _statePublisher: Any? + + var panGestureRecognizer: FloatingPanelPanGestureRecognizer let panGestureDelegateRouter: FloatingPanelPanGestureRecognizer.DelegateRouter var isRemovalInteractionEnabled: Bool = false @@ -139,6 +149,10 @@ class Core: NSObject, UIGestureRecognizerDelegate { super.init() + if #available(iOS 13.0, *) { + statePublisher = .init(.hidden) + } + panGestureRecognizer.set(floatingPanel: self) surfaceView.addGestureRecognizer(panGestureRecognizer) panGestureRecognizer.addTarget(self, action: #selector(handle(panGesture:))) @@ -159,11 +173,28 @@ class Core: NSObject, UIGestureRecognizerDelegate { self.moveAnimator?.stopAnimation(false) } - func move(to: FloatingPanelState, animated: Bool, completion: (() -> Void)? = nil) { - move(from: state, to: to, animated: animated, completion: completion) + func move( + to: FloatingPanelState, + animated: Bool, + moveAnimator: UIViewPropertyAnimator? = nil, + completion: (() -> Void)? = nil + ) { + move( + from: state, + to: to, + animated: animated, + moveAnimator: moveAnimator, + completion: completion + ) } - private func move(from: FloatingPanelState, to: FloatingPanelState, animated: Bool, completion: (() -> Void)? = nil) { + private func move( + from: FloatingPanelState, + to: FloatingPanelState, + animated: Bool, + moveAnimator: UIViewPropertyAnimator?, + completion: (() -> Void)? = nil + ) { assert(layoutAdapter.validStates.contains(to), "Can't move to '\(to)' state because it's not valid in the layout") guard let vc = ownerVC else { completion?() @@ -196,12 +227,15 @@ class Core: NSObject, UIGestureRecognizerDelegate { let animationVector = CGVector(dx: abs(removalVector.dx), dy: abs(removalVector.dy)) animator = vc.animatorForDismissing(with: animationVector) default: - startAttraction(to: to, with: .zero) { [weak self] in - self?.endAttraction(false) - updateScrollView() - completion?() + guard let moveAnimator = moveAnimator else { + startAttraction(to: to, with: .zero) { [weak self] in + self?.endAttraction(false) + updateScrollView() + completion?() + } + return } - return + animator = moveAnimator } let shouldDoubleLayout = from == .hidden @@ -283,6 +317,9 @@ class Core: NSObject, UIGestureRecognizerDelegate { layoutAdapter.activateLayout(for: target, forceLayout: true) backdropView.alpha = getBackdropAlpha(for: target) adjustScrollContentInsetIfNeeded() + if #available(iOS 13.0, *) { + statePublisher?.send(target) + } } private func getBackdropAlpha(for target: FloatingPanelState) -> CGFloat { @@ -1335,7 +1372,7 @@ class Core: NSObject, UIGestureRecognizerDelegate { } /// A gesture recognizer that looks for panning (dragging) gestures in a panel. -public final class FloatingPanelPanGestureRecognizer: UIPanGestureRecognizer { +public class FloatingPanelPanGestureRecognizer: UIPanGestureRecognizer { /// The gesture starting location in the surface view which it is attached to. fileprivate var initialLocation: CGPoint = .zero private weak var floatingPanel: Core! // Core has this gesture recognizer as non-optional diff --git a/Sources/Extensions.swift b/Sources/Extensions.swift index 8d635855d..d9128c386 100644 --- a/Sources/Extensions.swift +++ b/Sources/Extensions.swift @@ -214,4 +214,73 @@ extension UIBezierPath { cornerRadii: CGSize(width: cornerRadius, height: cornerRadius)) } + + #if compiler(>=6.2) + @available(iOS 26.0, *) + static func path(roundedRect rect: CGRect, view: UIView) -> UIBezierPath { + // Apply inset to hide the gap between UIBezierPath's circular arcs + // and UICornerConfiguration's corner curves. + let rect = rect.insetBy(dx: 4, dy: 4) + + // Query the effective corner radius from the view using the new iOS 26 API + // This properly handles UICornerConfiguration including .fixed, .dynamic, and .continuous + let topLeadingRadius = view.effectiveRadius(corner: .topLeft) + let topTrailingRadius = view.effectiveRadius(corner: .topRight) + let bottomLeadingRadius = view.effectiveRadius(corner: .bottomLeft) + let bottomTrailingRadius = view.effectiveRadius(corner: .bottomRight) + + // Otherwise, create a path with individual corner radii + let path = UIBezierPath() + let minX = rect.minX + let minY = rect.minY + let maxX = rect.maxX + let maxY = rect.maxY + + // Start from top left, after the corner + path.move(to: CGPoint(x: minX + topLeadingRadius, y: minY)) + + // Top edge and top-right corner + path.addLine(to: CGPoint(x: maxX - topTrailingRadius, y: minY)) + if topTrailingRadius > 0 { + path.addArc(withCenter: CGPoint(x: maxX - topTrailingRadius, y: minY + topTrailingRadius), + radius: topTrailingRadius, + startAngle: -0.5 * .pi, + endAngle: 0, + clockwise: true) + } + + // Right edge and bottom-right corner + path.addLine(to: CGPoint(x: maxX, y: maxY - bottomTrailingRadius)) + if bottomTrailingRadius > 0 { + path.addArc(withCenter: CGPoint(x: maxX - bottomTrailingRadius, y: maxY - bottomTrailingRadius), + radius: bottomTrailingRadius, + startAngle: 0, + endAngle: .pi * 0.5, + clockwise: true) + } + + // Bottom edge and bottom-left corner + path.addLine(to: CGPoint(x: minX + bottomLeadingRadius, y: maxY)) + if bottomLeadingRadius > 0 { + path.addArc(withCenter: CGPoint(x: minX + bottomLeadingRadius, y: maxY - bottomLeadingRadius), + radius: bottomLeadingRadius, + startAngle: .pi * 0.5, + endAngle: .pi, + clockwise: true) + } + + // Left edge and top-left corner + path.addLine(to: CGPoint(x: minX, y: minY + topLeadingRadius)) + if topLeadingRadius > 0 { + path.addArc(withCenter: CGPoint(x: minX + topLeadingRadius, y: minY + topLeadingRadius), + radius: topLeadingRadius, + startAngle: .pi, + endAngle: .pi * 1.5, + clockwise: true) + } + + path.close() + return path + } + #endif } diff --git a/Sources/FloatingPanel.docc/FloatingPanel API Guide.md b/Sources/FloatingPanel.docc/FloatingPanel API Guide.md new file mode 120000 index 000000000..ba9177924 --- /dev/null +++ b/Sources/FloatingPanel.docc/FloatingPanel API Guide.md @@ -0,0 +1 @@ +../../Documentation/FloatingPanel API Guide.md \ No newline at end of file diff --git a/Sources/FloatingPanel.docc/FloatingPanel SwiftUI API Guide.md b/Sources/FloatingPanel.docc/FloatingPanel SwiftUI API Guide.md new file mode 120000 index 000000000..882ec8be3 --- /dev/null +++ b/Sources/FloatingPanel.docc/FloatingPanel SwiftUI API Guide.md @@ -0,0 +1 @@ +../../Documentation/FloatingPanel SwiftUI API Guide.md \ No newline at end of file diff --git a/Sources/FloatingPanel.docc/FloatingPanel.md b/Sources/FloatingPanel.docc/FloatingPanel.md index fe1ea0b15..b9fabb295 100644 --- a/Sources/FloatingPanel.docc/FloatingPanel.md +++ b/Sources/FloatingPanel.docc/FloatingPanel.md @@ -4,50 +4,66 @@ Create a user interface to display the related content and utilities alongside t ## Overview - FloatingPanel is a simple and easy-to-use UI component designed for a user interface featured in the Apple Maps, Shortcuts and Stocks app. The user interface displays related content and utilities alongside the main content. - ## Topics -### Essentials +### API Guides -- ``FloatingPanelController`` -- ``FloatingPanelControllerDelegate`` +- +- -### Views +### Creations -- ``SurfaceView`` -- ``SurfaceAppearance`` -- ``BackdropView`` -- ``GrabberView`` +- ``FloatingPanelController`` +- ``FloatingPanelControllerDelegate`` +- ``SwiftUICore/View/floatingPanel(coordinator:onEvent:content:)`` +- ``SwiftUICore/View/floatingPanelScrollTracking(proxy:onScrollViewDetected:)`` +- ``FloatingPanelCoordinator`` +- ``FloatingPanelDefaultCoordinator`` +- ``FloatingPanelProxy`` -### Gestures +### State -- ``FloatingPanelPanGestureRecognizer`` +- ``FloatingPanelState`` +- ``SwiftUICore/View/floatingPanelState(_:)`` -### Layouts and Anchors +### Layout - ``FloatingPanelLayout`` - ``FloatingPanelBottomLayout`` -- ``FloatingPanelLayoutAnchoring`` -- ``FloatingPanelLayoutAnchor`` -- ``FloatingPanelAdaptiveLayoutAnchor`` -- ``FloatingPanelIntrinsicLayoutAnchor`` +- ``FloatingPanelPosition`` +- ``SwiftUICore/View/floatingPanelLayout(_:)`` +- ``SwiftUICore/View/floatingPanelContentMode(_:)`` +- ``SwiftUICore/View/floatingPanelContentInsetAdjustmentBehavior(_:)`` -### States +### Behavior -- ``FloatingPanelState`` +- ``FloatingPanelBehavior`` +- ``FloatingPanelDefaultBehavior`` +- ``SwiftUICore/View/floatingPanelBehavior(_:)`` -### Positions +### Layout Properties -- ``FloatingPanelPosition`` +- ``FloatingPanelLayoutAnchoring`` +- ``FloatingPanelLayoutAnchor`` +- ``FloatingPanelAdaptiveLayoutAnchor`` +- ``FloatingPanelIntrinsicLayoutAnchor`` - ``FloatingPanelReferenceEdge`` - ``FloatingPanelLayoutReferenceGuide`` - ``FloatingPanelLayoutContentBoundingGuide`` -### Behaviors +### Appearance + +- ``SurfaceView`` +- ``SurfaceAppearance`` +- ``GrabberView`` +- ``BackdropView`` +- ``SwiftUICore/View/floatingPanelSurfaceAppearance(_:)`` +- ``SwiftUICore/View/floatingPanelGrabberHandlePadding(_:)`` + +### Gesture + +- ``FloatingPanelPanGestureRecognizer`` -- ``FloatingPanelBehavior`` -- ``FloatingPanelDefaultBehavior`` diff --git a/Sources/Info.plist b/Sources/Info.plist index 173b005c4..d50b42082 100644 --- a/Sources/Info.plist +++ b/Sources/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 2.8.8 + 3.2.1 CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/Sources/Layout.swift b/Sources/Layout.swift index 2118e4c67..bcce8cba4 100644 --- a/Sources/Layout.swift +++ b/Sources/Layout.swift @@ -306,7 +306,7 @@ class LayoutAdapter { } func surfaceLocation(for state: FloatingPanelState) -> CGPoint { - let pos = position(for: state).rounded(by: surfaceView.fp_displayScale) + let pos = position(for: state) switch layout.position { case .top, .bottom: return CGPoint(x: 0.0, y: pos) @@ -316,6 +316,10 @@ class LayoutAdapter { } func position(for state: FloatingPanelState) -> CGFloat { + return _position(for: state).rounded(by: surfaceView.fp_displayScale) + } + + private func _position(for state: FloatingPanelState) -> CGFloat { let bounds = vc.view.bounds let anchor = layout.anchors[state] ?? self.hiddenAnchor diff --git a/Sources/SurfaceView.swift b/Sources/SurfaceView.swift index 508d4e73f..08b1264cf 100644 --- a/Sources/SurfaceView.swift +++ b/Sources/SurfaceView.swift @@ -45,6 +45,16 @@ public class SurfaceAppearance: NSObject { } }() + #if compiler(>=6.2) + @available(iOS 26.0, *) + public var cornerConfiguration: UICornerConfiguration? { + get { _cornerConfiguration as? UICornerConfiguration } + set { _cornerConfiguration = newValue } + } + #endif + + private var _cornerConfiguration: Any? + /// The radius to use when drawing the top rounded corners. /// /// `self.contentView` is masked with the top rounded corners automatically on iOS 11 and later. @@ -349,8 +359,23 @@ public class SurfaceView: UIView { let spread = shadow.spread let shadowRect = containerView.frame.insetBy(dx: -spread, dy: -spread) - let shadowPath = UIBezierPath.path(roundedRect: shadowRect, + + // Create shadow path based on corner configuration or corner radius + let shadowPath: UIBezierPath + #if compiler(>=6.2) + if #available(iOS 26.0, *), appearance.cornerConfiguration != nil { + // Use UIView.effectiveRadius(corner:) API for iOS 26+ + // This properly queries the actual corner radius from UICornerConfiguration + shadowPath = UIBezierPath.path(roundedRect: shadowRect, view: containerView) + } else { + shadowPath = UIBezierPath.path(roundedRect: shadowRect, appearance: appearance) + } + #else + shadowPath = UIBezierPath.path(roundedRect: shadowRect, + appearance: appearance) + #endif + shadowLayer.shadowPath = shadowPath.cgPath shadowLayer.shadowColor = shadow.color.cgColor shadowLayer.shadowOffset = shadow.offset @@ -359,23 +384,56 @@ public class SurfaceView: UIView { shadowLayer.shadowOpacity = shadow.opacity let mask = CAShapeLayer() - let path = UIBezierPath.path(roundedRect: containerView.frame, - appearance: appearance) + + // Create mask path based on corner configuration or corner radius + let path: UIBezierPath + #if compiler(>=6.2) + if #available(iOS 26.0, *), appearance.cornerConfiguration != nil { + // Use UIView.effectiveRadius(corner:) API for iOS 26+ + // This properly queries the actual corner radius from UICornerConfiguration + path = UIBezierPath.path(roundedRect: containerView.frame, view: containerView) + } else { + path = UIBezierPath.path(roundedRect: containerView.frame, + appearance: appearance) + } + #else + path = UIBezierPath.path(roundedRect: containerView.frame, + appearance: appearance) + #endif + let size = window?.bounds.size ?? CGSize(width: 1000.0, height: 1000.0) path.append(UIBezierPath(rect: layer.bounds.insetBy(dx: -size.width, dy: -size.height))) mask.fillRule = .evenOdd mask.path = path.cgPath + + #if compiler(>=6.2) + if #available(iOS 26.0, *), appearance.cornerConfiguration != nil { + // Corner curve is handled by UICornerConfiguration + } else if #available(iOS 13.0, *) { + containerView.layer.cornerCurve = appearance.cornerCurve + mask.cornerCurve = appearance.cornerCurve + } + #else if #available(iOS 13.0, *) { containerView.layer.cornerCurve = appearance.cornerCurve mask.cornerCurve = appearance.cornerCurve } + #endif + shadowLayer.mask = mask } CATransaction.commit() } private func updateCornerRadius() { + #if compiler(>=6.2) + if #available(iOS 26.0, *), let cornerConfiguration = appearance.cornerConfiguration { + containerView.cornerConfiguration = cornerConfiguration + containerView.layer.masksToBounds = true + return + } + #endif containerView.layer.cornerRadius = appearance.cornerRadius guard containerView.layer.cornerRadius != 0.0 else { containerView.layer.masksToBounds = false diff --git a/Sources/SwiftUI/FloatingPanelCoordinator.swift b/Sources/SwiftUI/FloatingPanelCoordinator.swift new file mode 100644 index 000000000..408b4def5 --- /dev/null +++ b/Sources/SwiftUI/FloatingPanelCoordinator.swift @@ -0,0 +1,125 @@ +// Copyright 2025 the FloatingPanel authors. All rights reserved. MIT license. + +#if canImport(SwiftUI) +import SwiftUI + +/// A protocol that defines the coordination between SwiftUI and UIKit for FloatingPanel integration. +/// +/// The FloatingPanelCoordinator is responsible for managing the connection between the SwiftUI view hierarchy +/// and the underlying ``FloatingPanelController``. It handles the setup, configuration, and event dispatching +/// for floating panels within SwiftUI. +/// +/// Implementations of this protocol should handle the following responsibilities: +/// - Creating and configuring the FloatingPanelController +/// - Setting up the content and main views +/// - Managing panel state and position +/// - Handling events and passing them back to SwiftUI +/// +/// By default, you can use the built-in ``FloatingPanelDefaultCoordinator`` for basic floating panel integration, +/// or implement a custom coordinator for more advanced functionality and control over events. +/// +/// To implement a custom coordinator, you must: +/// 1. Define an associated Event type based on the events you want to monitor +/// 2. Implement the required initializer and properties +/// 3. Handle the setup of the floating panel with the provided hosting controllers +/// 4. Optionally provide custom implementation for `onUpdate` method +@available(iOS 14, *) +public protocol FloatingPanelCoordinator { + /// The type of events this coordinator can dispatch to its host view. + /// + /// This can be an empty enum like in `FloatingPanelDefaultCoordinator.Event` if you don't need + /// to handle any events, or a more complex type that represents the various events that can occur + /// in your floating panel implementation. + associatedtype Event + + /// Creates a new coordinator with an action handler for events. + /// + /// - Parameter action: A closure that will be called when events occur in the floating panel. + /// The closure takes an event of the associated `Event` type and performs any necessary actions. + init(action: @escaping (Event) -> Void) + + /// A proxy object that provides access to the underlying FloatingPanelController. + /// + /// Use this property to interact with the floating panel, such as moving it to different states + /// or tracking scroll views. + var proxy: FloatingPanelProxy { get } + + /// Sets up the floating panel with main and content views. + /// + /// This method is called during the creation of the floating panel to configure the relationship + /// between the main view (the view containing the floating panel) and the content view (the view + /// displayed within the floating panel). + /// + /// - Parameters: + /// - mainHostingController: The UIHostingController that hosts the main SwiftUI view. + /// - contentHostingController: The UIHostingController that hosts the content SwiftUI view + /// to be displayed in the floating panel. + func setupFloatingPanel( + mainHostingController: UIHostingController
, + contentHostingController: UIHostingController + ) + + /// Called when the SwiftUI context updates. + /// + /// Use this method to respond to changes in the SwiftUI environment or to update + /// the floating panel's configuration based on new context. + /// + /// - Parameter context: The UIViewControllerRepresentableContext providing context for the update. + func onUpdate( + context: UIViewControllerRepresentableContext + ) where Representable: UIViewControllerRepresentable +} + +@available(iOS 14, *) +extension FloatingPanelCoordinator { + /// A convenience property that returns the underlying FloatingPanelController. + /// + /// This property provides direct access to the controller for advanced configurations + /// and operations. + public var controller: FloatingPanelController { + proxy.controller + } + +} + +/// A default implementation of the `FloatingPanelCoordinator` protocol. +/// +/// This coordinator provides a simple implementation for setting up the floating panel with +/// minimal configuration. It creates a standard ``FloatingPanelController`` with default settings +/// and an empty event enumeration. Use this coordinator for basic floating panel integration +/// when you don't need custom event handling or special configuration. +@available(iOS 14, *) +public final class FloatingPanelDefaultCoordinator: FloatingPanelCoordinator { + public enum Event {} + + public let proxy: FloatingPanelProxy + public let action: (FloatingPanelDefaultCoordinator.Event) -> Void + + public init(action: @escaping (FloatingPanelDefaultCoordinator.Event) -> Void) { + self.action = action + self.proxy = .init(controller: FloatingPanelController()) + } + + /// Default implementation for setting up the floating panel with main and content views. + /// + /// - Parameters: + /// - mainHostingController: The UIHostingController that hosts the main SwiftUI view. + /// - contentHostingController: The UIHostingController that hosts the content SwiftUI view + /// to be displayed in the floating panel. + public func setupFloatingPanel( + mainHostingController: UIHostingController
, + contentHostingController: UIHostingController + ) { + // Set up the content + contentHostingController.view.backgroundColor = .clear + controller.set(contentViewController: contentHostingController) + + // Show the panel + controller.addPanel(toParent: mainHostingController, animated: false) + } + + public func onUpdate( + context: UIViewControllerRepresentableContext + ) where Representable: UIViewControllerRepresentable {} +} +#endif diff --git a/Sources/SwiftUI/FloatingPanelProxy.swift b/Sources/SwiftUI/FloatingPanelProxy.swift new file mode 100644 index 000000000..f786c9909 --- /dev/null +++ b/Sources/SwiftUI/FloatingPanelProxy.swift @@ -0,0 +1,98 @@ +// Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. + +#if canImport(SwiftUI) +import SwiftUI + +/// A proxy for exposing and controlling the floating panel within SwiftUI views. +/// +/// `FloatingPanelProxy` provides a bridge between SwiftUI views and the underlying +/// `FloatingPanelController`, enabling you to programmatically interact with the +/// floating panel from your SwiftUI content. This proxy is automatically provided to +/// the content view through the `floatingPanel()` modifier's content closure. +/// +/// Use this proxy to: +/// - Programmatically move the panel to different positions +/// - Access the underlying UIKit controller for advanced customization +/// +/// ```swift +/// MyView() +/// .floatingPanel { proxy in +/// ScrollView { +/// VStack { +/// // Your content +/// +/// Button("Move To Full") { +/// proxy.move(to: .full, animated: true) +/// } +/// } +/// } +/// } +/// ``` +@available(iOS 14, *) +public struct FloatingPanelProxy { + /// The associated floating panel controller. + /// + /// This gives direct access to the underlying `FloatingPanelController` instance, + /// allowing you to use any features not directly exposed by the proxy methods. + /// Use this property when you need advanced control over the panel's behavior. + public let controller: FloatingPanelController + + public init(controller: FloatingPanelController) { + self.controller = controller + } + + /// Moves the floating panel to the specified position. + /// + /// Use this method to programmatically change the panel's position in response to + /// user actions or application state changes. The available positions are defined + /// by the current `FloatingPanelLayout` and typically include `.full`, `.half`, + /// and `.tip`. + /// + /// ```swift + /// Button("Show Full Panel") { + /// proxy.move(to: .full, animated: true) + /// } + /// ``` + /// + /// You can also use this method with a completion handler to perform actions + /// after the panel has finished moving: + /// + /// ```swift + /// proxy.move(to: .full, animated: true) { + /// // Code to execute after the panel reaches the full position + /// self.loadDetailedData() + /// } + /// ``` + /// + /// - Parameters: + /// - floatingPanelState: The state to move to (e.g., `.full`, `.half`, `.tip`). + /// The available states depend on the current `FloatingPanelLayout`. + /// - animated: `true` to animate the transition to the new state; `false` + /// for an immediate transition without animation. + /// - completion: An optional closure that will be executed after the panel + /// has completed moving to the new position. + public func move( + to floatingPanelState: FloatingPanelState, + animated: Bool, + completion: (() -> Void)? = nil + ) { + // Need to use this method which doesn't use the custom NumericSpringingAnimator because it doesn't work with + // SwiftUI animation. + controller.moveForSwiftUI(to: floatingPanelState, animated: animated, completion: completion) + } +} + +@available(iOS 14, *) +extension FloatingPanelProxy { + /// Tracks the specified scroll view to coordinate panel and scroll movements. + /// + /// - Important: It is strongly recommended to use ``SwiftUICore/View/floatingPanelScrollTracking(proxy:onScrollViewDetected:)`` + /// instead of this method, as it provides a more SwiftUI-friendly approach to scroll tracking. + /// + /// - Parameter scrollView: The scroll view to track. The panel will coordinate + /// its movements with this scroll view. + public func track(scrollView: UIScrollView) { + controller.track(scrollView: scrollView) + } +} +#endif diff --git a/Sources/SwiftUI/FloatingPanelView.swift b/Sources/SwiftUI/FloatingPanelView.swift new file mode 100644 index 000000000..b11e2aeb9 --- /dev/null +++ b/Sources/SwiftUI/FloatingPanelView.swift @@ -0,0 +1,304 @@ +// Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. + +#if canImport(SwiftUI) +import SwiftUI +import Combine + +/// A SwiftUI view that integrates a floating panel with customizable content. +/// +/// ``FloatingPanelView`` provides a SwiftUI wrapper around the UIKit-based ``FloatingPanelController``, +/// allowing you to easily add floating panels to your SwiftUI interface. The view consists of +/// two main components: +/// +/// - A main view that serves as the background or parent view +/// - A floating panel that contains custom content and can be positioned and animated +/// +/// While you can use this view directly, it's recommended to use the ``SwiftUICore/View/floatingPanel(coordinator:onEvent:content:)`` +/// view modifier instead, which provides a more SwiftUI-friendly API: +/// +/// ```swift +/// MyView() +/// .floatingPanel { proxy in +/// // Your floating panel content +/// Text("Panel Content") +/// } +/// .floatingPanelLayout(MyCustomLayout()) +/// .floatingPanelBehavior(MyCustomBehavior()) +/// ``` +/// +/// You can also provide a custom coordinator and handle events: +/// +/// ```swift +/// MyView() +/// .floatingPanel( +/// coordinator: MyCustomCoordinator.self, +/// onEvent: { event in +/// // Handle panel events +/// } +/// ) { proxy in +/// // Your floating panel content +/// } +/// ``` +/// +/// By default, ``FloatingPanelView`` uses ``FloatingPanelDefaultCoordinator`` to manage the +/// relationship between SwiftUI and UIKit components, but you can provide a custom +/// coordinator for more advanced control and event handling. +@available(iOS 14, *) +struct FloatingPanelView: UIViewControllerRepresentable { + /// A closure that creates the coordinator responsible for managing the floating panel. + let coordinator: () -> (any FloatingPanelCoordinator) + + /// The view builder that creates the main content underneath the floating panel. + @ViewBuilder + var main: MainView + + /// The view builder that creates the content displayed inside the floating panel. + @ViewBuilder + var content: (FloatingPanelProxy) -> ContentView + + /// A binding to the floating panel's current anchor state. + @Environment(\.state) + private var state: Binding + + /// The layout object that defines the position and size of the floating panel. + @Environment(\.layout) + private var layout: FloatingPanelLayout + + /// The behavior object that defines the interaction dynamics of the floating panel. + @Environment(\.behavior) + private var behavior: FloatingPanelBehavior + + /// The behavior for determining the adjusted content insets in the panel. + @Environment(\.contentInsetAdjustmentBehavior) + private var contentInsetAdjustmentBehavior + + /// Constants that define how a panel's content fills the surface. + @Environment(\.contentMode) + private var contentMode + + /// The vertical padding between the grabber handle and the content. + @Environment(\.grabberHandlePadding) + private var grabberHandlePadding + + /// The appearance configuration for the floating panel's surface view. + @Environment(\.surfaceAppearance) + private var surfaceAppearance + + func makeCoordinator() -> FloatingPanelCoordinatorProxy { + return FloatingPanelCoordinatorProxy( + coordinator: coordinator(), + state: state + ) + } + + func makeUIViewController(context: Context) -> UIHostingController { + let mainHostingController = UIHostingController(rootView: main) + mainHostingController.view.backgroundColor = nil + let contentHostingController = UIHostingController(rootView: content(context.coordinator.proxy)) + context.coordinator.setupFloatingPanel( + mainHostingController: mainHostingController, + contentHostingController: contentHostingController + ) + + context.coordinator.observeStateChanges() + context.coordinator.update(layout: layout, behavior: behavior) + + return mainHostingController + } + + func updateUIViewController( + _ uiViewController: UIHostingController, + context: Context + ) { + uiViewController.rootView = main + + context.coordinator.updateContent(content(context.coordinator.proxy)) + context.coordinator.onUpdate(context: context) + + applyEnvironment(context: context) + applyAnimatableEnvironment(context: context) + } +} + +@available(iOS 14, *) +extension FloatingPanelView { + // MARK: - Environment updates + /// Applies environment values to the floating panel controller. + func applyEnvironment(context: Context) { + let fpc = context.coordinator.controller + if fpc.contentInsetAdjustmentBehavior != contentInsetAdjustmentBehavior { + fpc.contentInsetAdjustmentBehavior = contentInsetAdjustmentBehavior + } + if fpc.contentMode != contentMode { + fpc.contentMode = contentMode + } + if fpc.surfaceView.grabberHandlePadding != grabberHandlePadding { + fpc.surfaceView.grabberHandlePadding = grabberHandlePadding + } + if fpc.surfaceView.appearance != surfaceAppearance { + fpc.surfaceView.appearance = surfaceAppearance + } + } + + /// Applies environment values to the floating panel controller with animations if needed. + func applyAnimatableEnvironment(context: Context) { + context.coordinator.apply( + animatableChanges: { + context.coordinator.update(state: state.wrappedValue) + context.coordinator.update(layout: layout, behavior: behavior) + }, + transaction: context.transaction + ) + } +} + +/// A proxy for exposing and controlling a client coordinator object. +/// +/// This proxy is introduced to make the implementation more extensible, rather than directly treating a Coordinator +/// with a lifecycle that spans across FloatingPanelView as a FloatingPanelCoordinator. This object was created to +/// control `FloatingPanelView/state` binding property. +@available(iOS 14, *) +class FloatingPanelCoordinatorProxy { + private let origin: any FloatingPanelCoordinator + private var stateBinding: Binding + + private var subscriptions: Set = Set() + + // Store a reference to the content hosting controller for dynamic updates + private weak var contentHostingController: UIViewController? + + var proxy: FloatingPanelProxy { origin.proxy } + var controller: FloatingPanelController { origin.controller } + + init( + coordinator: any FloatingPanelCoordinator, + state: Binding + ) { + self.origin = coordinator + self.stateBinding = state + } + + deinit { + for subscription in subscriptions { + subscription.cancel() + } + } + + func setupFloatingPanel( + mainHostingController: UIHostingController
, + contentHostingController: UIHostingController + ) { + // Store the content hosting controller reference + self.contentHostingController = contentHostingController + + origin.setupFloatingPanel( + mainHostingController: mainHostingController, + contentHostingController: contentHostingController + ) + } + + /// Updates the content of the floating panel with new content. + func updateContent(_ newContent: Content) { + guard + let hostingController = contentHostingController as? UIHostingController + else { + return + } + hostingController.rootView = newContent + } + + func onUpdate( + context: UIViewControllerRepresentableContext + ) where Representable: UIViewControllerRepresentable { + origin.onUpdate(context: context) + } +} + +@available(iOS 14, *) +extension FloatingPanelCoordinatorProxy { + // MARK: - Layout and behavior updates + + /// Update layout and behavior objects for the specified floating panel. + func update( + layout: (any FloatingPanelLayout)?, + behavior: (any FloatingPanelBehavior)? + ) { + let shouldInvalidateLayout = controller.layout !== layout + + if let layout = layout { + controller.layout = layout + } else { + controller.layout = FloatingPanelBottomLayout() + } + + if shouldInvalidateLayout { + controller.invalidateLayout() + } + + if let behavior = behavior { + controller.behavior = behavior + } else { + controller.behavior = FloatingPanelDefaultBehavior() + } + } +} + +@available(iOS 14, *) +extension FloatingPanelCoordinatorProxy { + // MARK: - State updates + + // Update the state of FloatingPanelController + func update(state: FloatingPanelState?) { + guard let state = state else { return } + controller.move(to: state, animated: false) + } + + /// Start observing ``FloatingPanelController/state`` through the `Core` object. + func observeStateChanges() { + controller.floatingPanel.statePublisher? + .sink { [weak self] state in + guard let self = self else { return } + // Needs to update the state binding value on the next run loop cycle to avoid this error. + // > Modifying state during view update, this will cause undefined behavior. + Task { @MainActor in + self.stateBinding.wrappedValue = state + } + }.store(in: &subscriptions) + } +} + +@available(iOS 14, *) +extension FloatingPanelCoordinatorProxy { + // MARK: - Environment updates + + /// Applies animatable environment value changes. + func apply(animatableChanges: @escaping () -> Void, transaction: Transaction) { + /// Returns the default animator object for compatibility with iOS 17 and earlier. + func animateUsingDefaultAnimator(changes: @escaping () -> Void) { + let animator = controller.makeDefaultAnimator() + animator.addAnimations(changes) + animator.startAnimation() + } + + if let animation = transaction.animation, transaction.disablesAnimations == false { + #if compiler(>=6.0) + if #available(iOS 18, *) { + UIView.animate(animation) { + animatableChanges() + } + } else { + animateUsingDefaultAnimator { + animatableChanges() + } + } + #else + animateUsingDefaultAnimator { + animatableChanges() + } + #endif + } else { + animatableChanges() + } + } +} +#endif diff --git a/Sources/SwiftUI/SurfaceAppearance+.swift b/Sources/SwiftUI/SurfaceAppearance+.swift new file mode 100644 index 000000000..8c2b98930 --- /dev/null +++ b/Sources/SwiftUI/SurfaceAppearance+.swift @@ -0,0 +1,80 @@ +// Copyright 2018 the FloatingPanel authors. All rights reserved. MIT license. + +#if canImport(SwiftUI) +import SwiftUI + +@available(iOS 14, *) +extension FloatingPanel.SurfaceAppearance { + /// Creates a transparent surface appearance with customizable borders, corners, and shadows. + /// + /// This utility method makes it easy to create visually appealing panel surfaces with + /// common styling options like borders and shadows. The surface is transparent by default, + /// allowing you to add background effects through your content using SwiftUI views if needed. + /// + /// Example usage: + /// + /// ```swift + /// MainView() + /// .floatingPanel { _ in + /// ZStack { + /// // Your panel content + /// VStack { + /// Text("Panel Title") + /// // ... + /// } + /// .padding() + /// } + /// /// A material effect background within your content + /// .background { + /// GeometryReader { geometry in + /// Rectangle() + /// .fill(.clear) + /// .frame(height: geometry.size.height * 2) + /// .background(.regularMaterial) + /// } + /// } + /// } + /// .floatingPanelSurfaceAppearance( + /// .transparent( + /// borderColor: .secondary.opacity(0.3), + /// borderWidth: 1.0, + /// cornerRadius: 16.0, + /// shadows: [ + /// .init(color: .black, radius: 10, opacity: 0.1, offset: .zero), + /// .init(color: .black, radius: 3, opacity: 0.1, offset: CGSize(width: 0, height: 2)) + /// ] + /// ) + /// ) + /// ``` + /// + /// - Parameters: + /// - borderColor: The color of the border around the panel's edges. Pass `nil` for no border. + /// - borderWidth: The width of the border in points. Defaults to 0.0. + /// - cornerRadius: The radius of the panel's corners in points. Defaults to 8.0. + /// - shadows: An array of `Shadow` objects defining layered shadow effects. + /// Defaults to a single subtle shadow. + /// + /// - Returns: A configured `SurfaceAppearance` instance with the specified styling. + public static func transparent( + borderColor: Color? = nil, + borderWidth: Double = 0.0, + cornerRadius: Double = 8.0, + shadows: [Shadow] = [Shadow()] + ) -> SurfaceAppearance { + let appearance = SurfaceAppearance() + appearance.backgroundColor = .clear + let borderUIColor: UIColor? + if let borderColor { + borderUIColor = UIColor(borderColor) + } else { + borderUIColor = nil + } + appearance.borderColor = borderUIColor + appearance.borderWidth = CGFloat(borderWidth) + appearance.cornerCurve = .continuous + appearance.cornerRadius = cornerRadius + appearance.shadows = shadows + return appearance + } +} +#endif diff --git a/Sources/SwiftUI/View+floatingPanel.swift b/Sources/SwiftUI/View+floatingPanel.swift new file mode 100644 index 000000000..24905a47b --- /dev/null +++ b/Sources/SwiftUI/View+floatingPanel.swift @@ -0,0 +1,64 @@ +// Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. + +#if canImport(SwiftUI) +import SwiftUI + +@available(iOS 14, *) +extension View { + /// Overlays this view with a floating panel. + /// + /// This modifier is the recommended way to add a floating panel to any SwiftUI view. + /// It creates a `FloatingPanelView` with the current view as the main content and + /// adds your custom content to the floating panel. + /// + /// ```swift + /// ScrollView { + /// LazyVStack { + /// // Main content + /// ForEach(items) { item in + /// ItemView(item) + /// } + /// } + /// } + /// .floatingPanel { proxy in + /// // Panel content + /// DetailView() + /// } + /// ``` + /// + /// You can customize the panel by using additional modifiers: + /// + /// ```swift + /// ContentView() + /// .floatingPanel { proxy in + /// PanelContent() + /// } + /// .floatingPanelLayout(MyCustomLayout()) + /// .floatingPanelBehavior(MyCustomBehavior()) + /// .floatingPanelSurfaceAppearance(MySurfaceAppearance()) + /// ``` + /// + /// - Parameters: + /// - coordinator: A coordinator type that conforms to the ``FloatingPanelCoordinator`` protocol. + /// Defaults to ``FloatingPanelDefaultCoordinator``. Use a custom coordinator for advanced control + /// over panel behavior and events. + /// - action: A closure that is called when events occur in the panel. The event type is defined + /// by the coordinator's associated `Event` type. This parameter is ignored if you use the default + /// coordinator, which doesn't emit events. + /// - content: A closure that returns the content to display in the floating panel. + /// This view builder receives a ``FloatingPanelProxy`` instance that you can use to + /// interact with the panel, such as tracking scroll views or moving the panel programmatically. + public func floatingPanel( + coordinator: T.Type = FloatingPanelDefaultCoordinator.self, + onEvent action: ((T.Event) -> Void)? = nil, + @ViewBuilder content: @escaping (FloatingPanelProxy) -> some View + ) -> some View { + FloatingPanelView( + coordinator: { T.init(action: action ?? { _ in }) }, + main: { self }, + content: content + ) + .ignoresSafeArea() + } +} +#endif diff --git a/Sources/SwiftUI/View+floatingPanelBehavior.swift b/Sources/SwiftUI/View+floatingPanelBehavior.swift new file mode 100644 index 000000000..a41482ae7 --- /dev/null +++ b/Sources/SwiftUI/View+floatingPanelBehavior.swift @@ -0,0 +1,62 @@ +// Copyright 2025 the FloatingPanel authors. All rights reserved. MIT license. + +#if canImport(SwiftUI) +import SwiftUI + +@available(iOS 14, *) +extension EnvironmentValues { + struct BehaviorKey: EnvironmentKey { + static var defaultValue: FloatingPanelBehavior = FloatingPanelDefaultBehavior() + } + + var behavior: FloatingPanelBehavior { + get { self[BehaviorKey.self] } + set { self[BehaviorKey.self] = newValue } + } +} + +@available(iOS 14, *) +extension View { + /// Sets the behavior object controlling the interactive dynamics of floating panels within this view. + /// + /// The behavior object defines how the floating panel responds to user interactions, + /// including: + /// - Momentum and velocity effects during dragging + /// - Position snapping behavior when released + /// - Projection behavior after a swipe gesture + /// - Interaction restrictions for certain positions + /// + /// By default, the panel uses `FloatingPanelDefaultBehavior`, but you can create + /// your own custom behavior by implementing the `FloatingPanelBehavior` protocol: + /// + /// ```swift + /// struct MyCustomBehavior: FloatingPanelBehavior { + /// func shouldProjectMomentum(_ fpc: FloatingPanelController, for proposedTargetPosition: FloatingPanelState) -> Bool { + /// return true + /// } + /// + /// func momentumProjection(from initialVelocity: CGPoint) -> CGPoint { + /// return CGPoint(x: 0, y: initialVelocity.y * 0.5) + /// } + /// } + /// ``` + /// + /// Apply the behavior to your floating panel: + /// + /// ```swift + /// MainView() + /// .floatingPanel { _ in + /// FloatingPanelContent() + /// } + /// .floatingPanelBehavior(MyCustomBehavior()) + /// ``` + /// + /// - Parameter behavior: An object conforming to the `FloatingPanelBehavior` protocol + /// that controls the panel's interactive dynamics, or `nil` to use the default behavior. + public func floatingPanelBehavior( + _ behavior: FloatingPanelBehavior? + ) -> some View { + environment(\.behavior, behavior ?? FloatingPanelDefaultBehavior()) + } +} +#endif diff --git a/Sources/SwiftUI/View+floatingPanelConfiguration.swift b/Sources/SwiftUI/View+floatingPanelConfiguration.swift new file mode 100644 index 000000000..16cc133eb --- /dev/null +++ b/Sources/SwiftUI/View+floatingPanelConfiguration.swift @@ -0,0 +1,102 @@ +// Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. + +#if canImport(SwiftUI) +import SwiftUI + +@available(iOS 14, *) +extension EnvironmentValues { + struct ContentInsetAdjustmentBehaviorKey: EnvironmentKey { + static var defaultValue: FloatingPanelController.ContentInsetAdjustmentBehavior = .always + } + + var contentInsetAdjustmentBehavior: FloatingPanelController.ContentInsetAdjustmentBehavior { + get { self[ContentInsetAdjustmentBehaviorKey.self] } + set { self[ContentInsetAdjustmentBehaviorKey.self] = newValue } + } + + struct ContentModeKey: EnvironmentKey { + static var defaultValue: FloatingPanelController.ContentMode = .static + } + + var contentMode: FloatingPanelController.ContentMode { + get { self[ContentModeKey.self] } + set { self[ContentModeKey.self] = newValue } + } +} + +@available(iOS 14, *) +extension View { + /// Sets the content mode for floating panels within this view. + /// + /// The content mode controls how the panel's content view is sized and positioned + /// when the panel's position changes. Each mode has different behavior: + /// + /// - `.static`: The content view maintains its current frame regardless of the + /// panel's position. This is the default mode and is suitable for most use cases + /// where the content should remain stable. + /// + /// - `.fitToBounds`: The content view is resized to fit within the panel's bounds + /// at each position. This is useful when you want the content to always fill + /// the available space within the panel. + /// + /// Example usage: + /// + /// ```swift + /// MainView() + /// .floatingPanel { _ in + /// VStack { + /// Text("Panel Content") + /// Image("illustration") + /// } + /// } + /// .floatingPanelContentMode(.fitToBounds) + /// ``` + /// + /// - Parameter contentMode: The content mode to use for the floating panel. + public func floatingPanelContentMode( + _ contentMode: FloatingPanelController.ContentMode + ) -> some View { + environment(\.contentMode, contentMode) + } + + /// Sets the content inset adjustment behavior for floating panels within this view. + /// + /// This modifier controls how the panel adjusts its content insets in relation to + /// the safe area and the panel's position. This is particularly important for + /// scrollable content within the panel. + /// + /// Available behaviors: + /// + /// - `.always`: Always adjust content insets to account for safe areas and panel + /// position. This ensures content is properly inset beneath system bars and panel + /// elements like the grabber handle. This is the default and recommended for most cases. + /// + /// - `.never`: Never adjust content insets. Content will extend to the edges of the + /// panel regardless of safe areas. Use this when you want to manually manage insets + /// or create custom overlay effects. + /// + /// Example usage: + /// + /// ```swift + /// MainView() + /// .floatingPanel { _ in + /// ScrollView { + /// LazyVStack { + /// ForEach(items) { item in + /// ItemRow(item) + /// } + /// } + /// } + /// } + /// .floatingPanelContentInsetAdjustmentBehavior(.always) + /// ``` + /// + /// - Parameter contentInsetAdjustmentBehavior: The content inset adjustment behavior + /// to use for the floating panel. + public func floatingPanelContentInsetAdjustmentBehavior( + _ contentInsetAdjustmentBehavior: FloatingPanelController.ContentInsetAdjustmentBehavior + ) -> some View { + environment(\.contentInsetAdjustmentBehavior, contentInsetAdjustmentBehavior) + } +} +#endif diff --git a/Sources/SwiftUI/View+floatingPanelLayout.swift b/Sources/SwiftUI/View+floatingPanelLayout.swift new file mode 100644 index 000000000..50399c272 --- /dev/null +++ b/Sources/SwiftUI/View+floatingPanelLayout.swift @@ -0,0 +1,71 @@ +// Copyright 2025 the FloatingPanel authors. All rights reserved. MIT license. + +#if canImport(SwiftUI) +import SwiftUI + +@available(iOS 14, *) +extension EnvironmentValues { + struct LayoutKey: EnvironmentKey { + static var defaultValue: FloatingPanelLayout = FloatingPanelBottomLayout() + } + + var layout: FloatingPanelLayout { + get { self[LayoutKey.self] } + set { self[LayoutKey.self] = newValue } + } +} + +@available(iOS 14, *) +extension View { + /// Sets the layout object that defines the position and dimensions of floating panels within this view. + /// + /// The layout object controls critical aspects of the floating panel's appearance: + /// - Available positions (full, half, tip, etc.) and their insets from screen edges + /// - Initial position when the panel first appears + /// - Anchoring behavior and constraints + /// - Layout adaptation for different size classes and device orientations + /// + /// FloatingPanel comes with several built-in layouts: + /// - `FloatingPanelBottomLayout`: Standard bottom-anchored panel (default) + /// + /// You can also create custom layouts by implementing the `FloatingPanelLayout` protocol: + /// + /// ```swift + /// struct MyCustomLayout: FloatingPanelLayout { + /// var position: FloatingPanelPosition { + /// return .bottom + /// } + /// + /// var initialState: FloatingPanelState { + /// return .half + /// } + /// + /// var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] { + /// return [ + /// .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea), + /// .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .safeArea), + /// .tip: FloatingPanelLayoutAnchor(absoluteInset: 44.0, edge: .bottom, referenceGuide: .safeArea) + /// ] + /// } + /// } + /// ``` + /// + /// Apply the layout to your floating panel: + /// + /// ```swift + /// MainView() + /// .floatingPanel { _ in + /// FloatingPanelContent() + /// } + /// .floatingPanelLayout(MyCustomLayout()) + /// ``` + /// + /// - Parameter layout: An object conforming to the `FloatingPanelLayout` protocol + /// that defines the panel's position and dimensions, or `nil` to use the default layout. + public func floatingPanelLayout( + _ layout: FloatingPanelLayout? + ) -> some View { + environment(\.layout, layout ?? FloatingPanelBottomLayout()) + } +} +#endif diff --git a/Sources/SwiftUI/View+floatingPanelScrollTracking.swift b/Sources/SwiftUI/View+floatingPanelScrollTracking.swift new file mode 100644 index 000000000..f288618df --- /dev/null +++ b/Sources/SwiftUI/View+floatingPanelScrollTracking.swift @@ -0,0 +1,123 @@ +// Copyright 2025 the FloatingPanel authors. All rights reserved. MIT license. + +#if canImport(SwiftUI) +import SwiftUI + +@available(iOS 14, *) +extension View { + /// Automatically tracks scroll views within this view for seamless integration with a floating panel. + /// + /// This modifier automatically detects and tracks a `UIScrollView` instance within your SwiftUI content, + /// linking it with the floating panel for coordinated scrolling behavior. This is essential for + /// creating a smooth user experience when a scrollable view is contained in a floating panel. + /// + /// Example usage: + /// + /// ```swift + /// MainView() + /// .floatingPanel { proxy in + /// ScrollView { + /// VStack(spacing: 20) { + /// ForEach(items) { item in + /// ItemRow(item) + /// } + /// } + /// .padding() + /// } + /// .floatingPanelScrollTracking(proxy: proxy) + /// } + /// ``` + /// + /// For advanced customization, you can provide an onScrollViewDetected closure to access the hosting controller + /// and scroll view directly: + /// + /// ```swift + /// .floatingPanelScrollTracking(proxy: proxy) { scrollView, _ in + /// // Customize scroll view behavior or appearance + /// scrollView.showsVerticalScrollIndicator = false + /// scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0) + /// } + /// ``` + /// + /// - Parameters: + /// - proxy: The ``FloatingPanelProxy`` instance from the floating panel's content closure. + /// - onScrollViewDetected: Optional closure called when a scroll view is found, allowing for additional customization. + /// The closure receives the detected scroll view and its hosting view controller in that order. + public func floatingPanelScrollTracking( + proxy: FloatingPanelProxy, + onScrollViewDetected: ((UIScrollView, UIHostingController) -> Void)? = nil + ) -> some View { + ScrollViewRepresentable(proxy: proxy, onScrollViewDetected: onScrollViewDetected) { self } + } +} + +@available(iOS 14, *) +private struct ScrollViewRepresentable: UIViewControllerRepresentable where Content: View { + let proxy: FloatingPanelProxy + let onScrollViewDetected: ((UIScrollView, UIHostingController) -> Void)? + @ViewBuilder + let content: () -> Content + + func makeUIViewController(context: Context) -> ScrollViewHostingController { + let vc = ScrollViewHostingController( + rootView: content(), + proxy: proxy, + onScrollViewDetected: onScrollViewDetected + ) + vc.view.backgroundColor = .clear + return vc + } + + func updateUIViewController( + _ uiViewController: ScrollViewHostingController, + context: Context + ) { + uiViewController.rootView = content() + } + + class ScrollViewHostingController: UIHostingController where V: View { + let proxy: FloatingPanelProxy + let onScrollViewDetected: ((UIScrollView, UIHostingController) -> Void)? + + private weak var detectedScrollView: UIScrollView? + + init( + rootView: V, + proxy: FloatingPanelProxy, + onScrollViewDetected: ((UIScrollView, UIHostingController) -> Void)? + ) { + self.proxy = proxy + self.onScrollViewDetected = onScrollViewDetected + super.init(rootView: rootView) + } + + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + if detectedScrollView == nil, + let scrollView = findUIScrollView(in: self.view) + { + proxy.track(scrollView: scrollView) + onScrollViewDetected?(scrollView, self) + detectedScrollView = scrollView + } + } + + func findUIScrollView(in root: UIView?) -> UIScrollView? { + guard let root = root else { return nil } + var queue = ArraySlice([root]) + while !queue.isEmpty { + let view = queue.popFirst() + if view?.isKind(of: UIScrollView.self) ?? false { + return (view as? UIScrollView) + } + queue += view?.subviews ?? [] + } + return nil + } + } +} +#endif diff --git a/Sources/SwiftUI/View+floatingPanelState.swift b/Sources/SwiftUI/View+floatingPanelState.swift new file mode 100644 index 000000000..a43cbee53 --- /dev/null +++ b/Sources/SwiftUI/View+floatingPanelState.swift @@ -0,0 +1,68 @@ +// Copyright 2025 the FloatingPanel authors. All rights reserved. MIT license. + +#if canImport(SwiftUI) +import SwiftUI + +@available(iOS 14, *) +extension EnvironmentValues { + struct StateKey: EnvironmentKey { + static var defaultValue: Binding = .constant(nil) + } + + var state: Binding { + get { self[StateKey.self] } + set { self[StateKey.self] = newValue } + } +} + +@available(iOS 14, *) +extension View { + /// Sets a binding to track and control the floating panel's state. + /// + /// - Important: The timing of changes to this state differs from the timing of + /// ``FloatingPanelController/state`` and ``FloatingPanelControllerDelegate/floatingPanelDidChangeState(_:)``. + /// This state updates slightly later due to differences between UIKit animations and SwiftUI view management. + /// + /// This modifier provides two-way communication with the floating panel: + /// - When the user interacts with the panel, the binding updates to reflect the new state + /// - When you programmatically change the binding value, the panel changes or animates to the new state + /// + /// You can use this binding to: + /// - Respond to state changes when the user interacts with the panel + /// - Programmatically control the panel position with SwiftUI animations + /// - Synchronize the panel state with other parts of your UI + /// + /// Example usage: + /// + /// ```swift + /// struct MainView: View { + /// @State private var panelState: FloatingPanelState? + /// + /// var body: some View { + /// ZStack { + /// Color.orange + /// .ignoresSafeArea() + /// .floatingPanel { _ in + /// ContentView() + /// } + /// .floatingPanelState($panelState) + /// + /// Button("Move to full") { + /// withAnimation(.interactiveSpring) { + /// panelState = .full + /// } + /// } + /// } + /// } + /// } + /// ``` + /// + /// - Parameter state: A binding to a `FloatingPanelState` value that tracks and controls + /// the current state of the floating panel. + public func floatingPanelState( + _ state: Binding + ) -> some View { + environment(\.state, state) + } +} +#endif diff --git a/Sources/SwiftUI/View+floatingPanelSurface.swift b/Sources/SwiftUI/View+floatingPanelSurface.swift new file mode 100644 index 000000000..7f9276fdb --- /dev/null +++ b/Sources/SwiftUI/View+floatingPanelSurface.swift @@ -0,0 +1,88 @@ +// Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license. + +#if canImport(SwiftUI) +import SwiftUI + +@available(iOS 14, *) +extension EnvironmentValues { + struct SurfaceAppearanceKey: EnvironmentKey { + static var defaultValue = SurfaceAppearance() + } + + var surfaceAppearance: SurfaceAppearance { + get { self[SurfaceAppearanceKey.self] } + set { self[SurfaceAppearanceKey.self] = newValue } + } + + struct GrabberHandlePaddingKey: EnvironmentKey { + static var defaultValue: CGFloat = 6.0 + } + + var grabberHandlePadding: CGFloat { + get { self[GrabberHandlePaddingKey.self] } + set { self[GrabberHandlePaddingKey.self] = newValue } + } +} + +@available(iOS 14, *) +extension View { + /// Sets the surface appearance for floating panels within this view. + /// + /// This modifier allows you to fully customize the visual styling of the floating panel's + /// surface, including background color, corner radius, shadows, and borders. + /// + /// Example using a pre-defined appearance: + /// + /// ```swift + /// MainView() + /// .floatingPanel { _ in + /// FloatingPanelContent() + /// } + /// .floatingPanelSurfaceAppearance(.transparent) + /// ``` + /// + /// - Parameter surfaceAppearance: The surface appearance to set for the floating panel. + public func floatingPanelSurfaceAppearance( + _ surfaceAppearance: SurfaceAppearance + ) -> some View { + environment(\.surfaceAppearance, surfaceAppearance) + } + + /// Sets the grabber handle padding for floating panels within this view. + /// + /// This modifier adjusts the vertical spacing between the grabber handle, such as the visual + /// indicator at the top of the bottom positioned panel that users can drag. + /// + /// Adjusting this value can help with: + /// - Visual balance and spacing within the panel + /// - Providing more space for touch interactions with the grabber + /// + /// ```swift + /// MainView() + /// .floatingPanel { _ in + /// VStack(spacing: 0) { + /// Text("Panel Title") + /// .font(.headline) + /// + /// Divider() + /// .padding(.vertical) + /// + /// // Panel content + /// } + /// .padding(.horizontal) + /// } + /// // Add more space between the grabber and content for visual balance + /// .floatingPanelGrabberHandlePadding(16) + /// ``` + /// + /// The default padding is 6.0 points. + /// + /// - Parameter padding: The vertical padding in points between the grabber handle + /// and the panel content. + public func floatingPanelGrabberHandlePadding( + _ padding: CGFloat + ) -> some View { + environment(\.grabberHandlePadding, padding) + } +} +#endif diff --git a/Tests/ControllerTests.swift b/Tests/ControllerTests.swift index 178dc2168..e0f41b162 100644 --- a/Tests/ControllerTests.swift +++ b/Tests/ControllerTests.swift @@ -64,7 +64,7 @@ class ControllerTests: XCTestCase { } func test_moveTo() { - let timeout = 3.0 + let timeout = 5.0 let delegate = FloatingPanelTestDelegate() let fpc = FloatingPanelController(delegate: delegate) XCTAssertEqual(delegate.position, .hidden) @@ -138,7 +138,7 @@ class ControllerTests: XCTestCase { class MyFloatingPanelTop2BottomLayout: FloatingPanelTop2BottomTestLayout { override var initialState: FloatingPanelState { return .half } } - let timeout = 3.0 + let timeout = 5.0 let delegate = FloatingPanelTestDelegate() let fpc = FloatingPanelController(delegate: delegate) fpc.layout = MyFloatingPanelTop2BottomLayout() @@ -225,7 +225,7 @@ class ControllerTests: XCTestCase { } func test_moveTo_didMoveDelegate() { - let timeout = 3.0 + let timeout = 5.0 let delegate = FloatingPanelTestDelegate() let fpc = FloatingPanelController(delegate: delegate) XCTAssertEqual(delegate.position, .hidden) diff --git a/Tests/CoreTests.swift b/Tests/CoreTests.swift index 8a18dd690..3057430be 100644 --- a/Tests/CoreTests.swift +++ b/Tests/CoreTests.swift @@ -1,6 +1,9 @@ // Copyright 2018 the FloatingPanel authors. All rights reserved. MIT license. import XCTest +#if canImport(Combine) +import Combine +#endif @testable import FloatingPanel class CoreTests: XCTestCase { @@ -8,6 +11,7 @@ class CoreTests: XCTestCase { override func tearDown() {} func test_scrollLock() { + let timeout = 5.0 let fpc = FloatingPanelController() let contentVC1 = UITableViewController(nibName: nil, bundle: nil) @@ -35,7 +39,7 @@ class CoreTests: XCTestCase { XCTAssertEqual(contentVC1.tableView.bounces, true) exp1.fulfill() } - wait(for: [exp1], timeout: 1.0) + wait(for: [exp1], timeout: timeout) let exp2 = expectation(description: "move to tip with animation") fpc.move(to: .tip, animated: false) { @@ -43,7 +47,7 @@ class CoreTests: XCTestCase { XCTAssertEqual(contentVC1.tableView.bounces, false) exp2.fulfill() } - wait(for: [exp2], timeout: 1.0) + wait(for: [exp2], timeout: timeout) // Reset the content vc let contentVC2 = UITableViewController(nibName: nil, bundle: nil) @@ -205,11 +209,9 @@ class CoreTests: XCTestCase { func floatingPanel(_ fpc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout { layout } func floatingPanel(_ fpc: FloatingPanelController, layoutFor size: CGSize) -> FloatingPanelLayout { layout } } - func _floor(_ alpha: CGFloat) -> CGFloat { - return floor(fpc.backdropView.alpha * 1e+06) / 1e+06 - } - let timeout = 3.0 + let acc = 0.000_001 + let timeout = 5.0 let delegate = TestDelegate() let fpc = FloatingPanelController(delegate: delegate) @@ -218,27 +220,27 @@ class CoreTests: XCTestCase { fpc.showForTest() fpc.move(to: .full, animated: false) - XCTAssertEqual(_floor(fpc.backdropView.alpha), 0.3) + XCTAssertEqual(fpc.backdropView.alpha, 0.3, accuracy: acc) fpc.move(to: .half, animated: false) - XCTAssertEqual(fpc.backdropView.alpha, 0.0) + XCTAssertEqual(fpc.backdropView.alpha, 0.0, accuracy: acc) fpc.move(to: .tip, animated: false) - XCTAssertEqual(_floor(fpc.backdropView.alpha), 0.3) + XCTAssertEqual(fpc.backdropView.alpha, 0.3, accuracy: acc) let exp1 = expectation(description: "move to full with animation") fpc.move(to: .full, animated: true) { exp1.fulfill() } wait(for: [exp1], timeout: timeout) - XCTAssertEqual(_floor(fpc.backdropView.alpha), 0.3) + XCTAssertEqual(fpc.backdropView.alpha, 0.3, accuracy: acc) let exp2 = expectation(description: "move to half with animation") fpc.move(to: .half, animated: true) { exp2.fulfill() } wait(for: [exp2], timeout: timeout) - XCTAssertEqual(fpc.backdropView.alpha, 0.0) + XCTAssertEqual(fpc.backdropView.alpha, 0.0, accuracy: acc) // Test a content mode change of FloatingPanelController @@ -249,12 +251,12 @@ class CoreTests: XCTestCase { fpc.contentMode = .fitToBounds XCTAssertEqual(fpc.backdropView.alpha, 0.0) // Must not affect the backdrop alpha by changing the content mode wait(for: [exp3], timeout: timeout) - XCTAssertEqual(_floor(fpc.backdropView.alpha), 0.3) + XCTAssertEqual(fpc.backdropView.alpha, 0.3, accuracy: acc) // Test a size class change of FloatingPanelController.view fpc.move(to: .full, animated: false) - XCTAssertEqual(_floor(fpc.backdropView.alpha), 0.3) + XCTAssertEqual(fpc.backdropView.alpha, 0.3, accuracy: acc) fpc.willTransition(to: UITraitCollection(horizontalSizeClass: .regular), with: MockTransitionCoordinator()) XCTAssertEqual(fpc.backdropView.alpha, 0.0) // Must update the alpha by BackdropTestLayout2 in TestDelegate. @@ -263,7 +265,7 @@ class CoreTests: XCTestCase { fpc.move(to: .full, animated: false) delegate.layout = BackdropTestLayout() fpc.invalidateLayout() - XCTAssertEqual(_floor(fpc.backdropView.alpha), 0.3) + XCTAssertEqual(fpc.backdropView.alpha, 0.3, accuracy: acc) delegate.layout = BackdropTestLayout2() fpc.viewWillTransition(to: CGSize.zero, with: MockTransitionCoordinator()) @@ -959,7 +961,22 @@ class CoreTests: XCTestCase { } func test_initial_scroll_offset_reset() { + class MockPanGestureRecognizer: FloatingPanelPanGestureRecognizer { + var _view: UIView? + override var view: UIView? { + set { _view = newValue } + get { _view } + } + var _state: UIGestureRecognizer.State = .possible + override var state: UIGestureRecognizer.State { + set { _state = newValue } + get { _state } + } + } let fpc = FloatingPanelController() + let mockGesture = MockPanGestureRecognizer() + mockGesture.view = fpc.floatingPanel.surfaceView + fpc.floatingPanel.panGestureRecognizer = mockGesture let scrollView = UIScrollView() fpc.layout = FloatingPanelBottomLayout() fpc.track(scrollView: scrollView) @@ -967,6 +984,7 @@ class CoreTests: XCTestCase { fpc.move(to: .full, animated: false) + fpc.panGestureRecognizer.state = .began fpc.floatingPanel.handle(panGesture: fpc.panGestureRecognizer) @@ -979,6 +997,8 @@ class CoreTests: XCTestCase { scrollView.setContentOffset(expect, animated: false) + XCTAssertEqual(expect, scrollView.contentOffset) + fpc.move(to: .half, animated: true) waitRunLoop(secs: 1.0) @@ -1005,6 +1025,45 @@ class CoreTests: XCTestCase { XCTAssertEqual(fpc.surfaceLocation(for: .full).y, fpc.surfaceLocation.y) XCTAssertEqual(delegate.willAttract, false) } + + @available(iOS 13.0, *) + func test_statePublisher() throws { + let fpc = FloatingPanelController() + fpc.showForTest() + + XCTAssertEqual(fpc.state, .half) + + // Verify statePublisher is available on iOS 13+ + XCTAssertNotNil(fpc.floatingPanel.statePublisher) + + var receivedStates: [FloatingPanelState] = [] + var cancellables = Set() + + // Subscribe to statePublisher + fpc.floatingPanel.statePublisher? + .sink { state in + receivedStates.append(state) + } + .store(in: &cancellables) + + // The initial state should be emitted first + XCTAssertEqual(receivedStates, [.half]) + + // Move to .full + fpc.move(to: .full, animated: false) + XCTAssertEqual(fpc.state, .full) + XCTAssertEqual(receivedStates, [.half, .full]) + + // Move to .tip + fpc.move(to: .tip, animated: false) + XCTAssertEqual(fpc.state, .tip) + XCTAssertEqual(receivedStates, [.half, .full, .tip]) + + // Move back to .half + fpc.move(to: .half, animated: false) + XCTAssertEqual(fpc.state, .half) + XCTAssertEqual(receivedStates, [.half, .full, .tip, .half]) + } } private class FloatingPanelLayout3Positions: FloatingPanelTestLayout {