diff --git a/.circleci/config.yml b/.circleci/config.yml index 5d2aae3c..05c2d44f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,6 +1,27 @@ 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 @@ -8,7 +29,15 @@ jobs: - checkout - run: xcodebuild clean test -scheme FloatingPanel -workspace FloatingPanel.xcworkspace -destination 'platform=iOS Simulator,OS=14.5,name=iPhone 12 Pro' + workflows: test: jobs: - - test-ios14_5-iPhone_12_Pro + - 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 4f2e3508..1bcefcde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,9 @@ jobs: fail-fast: false matrix: include: + - swift: "5" + xcode: "16.2" + runs-on: macos-15 - swift: "5.10" xcode: "15.4" runs-on: macos-14 @@ -27,15 +30,6 @@ jobs: - swift: "5.8" xcode: "14.3.1" runs-on: macos-13 - - swift: "5.7" - xcode: "14.1" - runs-on: macos-12 - - swift: "5.6" - xcode: "13.4.1" - runs-on: macos-12 - - swift: "5.5" - xcode: "13.2.1" - runs-on: macos-12 steps: - uses: actions/checkout@v4 - name: Building in Swift ${{ matrix.swift }} @@ -49,6 +43,11 @@ jobs: fail-fast: false matrix: include: + - os: "18.2" + xcode: "16.2" + 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" @@ -59,11 +58,6 @@ jobs: sim: "iPhone 14 Pro" parallel: NO # Stop random test job failures runs-on: macos-13 - - os: "15.5" - xcode: "13.4.1" - sim: "iPhone 13 Pro" - parallel: NO # Stop random test job failures - runs-on: macos-12 steps: - uses: actions/checkout@v4 - name: Testing in iOS ${{ matrix.os }} @@ -76,9 +70,9 @@ jobs: timeout-minutes: 20 example: - runs-on: macos-14 + runs-on: macos-15 env: - DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer strategy: fail-fast: false matrix: @@ -90,6 +84,7 @@ jobs: 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 \ @@ -97,22 +92,32 @@ jobs: -sdk iphonesimulator swiftpm: - runs-on: macos-14 + runs-on: macos-15 env: - DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer strategy: fail-fast: false matrix: + xcode: ["16.2", "15.4"] platform: [iphoneos, iphonesimulator] arch: [x86_64, arm64] exclude: - platform: iphoneos arch: x86_64 include: + # 18.2 + - platform: iphoneos + xcode: "16.2" + sys: "ios18.2" + - platform: iphonesimulator + xcode: "16.2" + sys: "ios18.2-simulator" # 17.2 - platform: iphoneos + xcode: "15.4" sys: "ios17.2" - platform: iphonesimulator + xcode: "15.4" sys: "ios17.2-simulator" steps: - uses: actions/checkout@v4 @@ -137,13 +142,6 @@ jobs: - target: "arm64-apple-ios16.4-simulator" xcode: "14.3.1" runs-on: macos-13 - # 15.7 - - target: "x86_64-apple-ios15.7-simulator" - xcode: "14.1" - runs-on: macos-12 - - target: "arm64-apple-ios15.7-simulator" - xcode: "14.1" - runs-on: macos-12 steps: - uses: actions/checkout@v4 - name: "Swift Package Manager build" @@ -153,12 +151,12 @@ jobs: -Xswiftc "-target" -Xswiftc "${{ matrix.target }}" cocoapods: - runs-on: macos-14 + runs-on: macos-15 env: - DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer steps: - uses: actions/checkout@v4 - name: "CocoaPods: pod lib lint" - run: pod lib lint --allow-warnings + run: pod lib lint --allow-warnings --verbose - name: "CocoaPods: pod spec lint" - run: pod spec lint --allow-warnings + run: pod spec lint --allow-warnings --verbose diff --git a/Examples/Samples/Sources/PanelLayouts.swift b/Examples/Samples/Sources/PanelLayouts.swift index 8fae78bb..90a758af 100644 --- a/Examples/Samples/Sources/PanelLayouts.swift +++ b/Examples/Samples/Sources/PanelLayouts.swift @@ -76,3 +76,18 @@ class ModalPanelLayout: FloatingPanelLayout { return 0.3 } } + +class ModalPanelLayout2: FloatingPanelLayout { + let position: FloatingPanelPosition = .bottom + let initialState: FloatingPanelState = .half + var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] { + [ + .full: FloatingPanelLayoutAnchor(fractionalInset: 0.0, edge: .top, referenceGuide: .superview), + .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .superview) + ] + } + func backdropAlpha(for _: FloatingPanelState) -> CGFloat { + 0.6 + } +} + diff --git a/Examples/Samples/Sources/UseCases/UseCase.swift b/Examples/Samples/Sources/UseCases/UseCase.swift index 14fbe8e5..a555d1fa 100644 --- a/Examples/Samples/Sources/UseCases/UseCase.swift +++ b/Examples/Samples/Sources/UseCases/UseCase.swift @@ -9,6 +9,7 @@ enum UseCase: Int, CaseIterable { case showDetail case showModal case showPanelModal + case showPanelModal2 case showMultiPanelModal case showPanelInSheetModal case showOnWindow @@ -39,6 +40,7 @@ extension UseCase { case .showDetail: return "Show Detail Panel" case .showModal: return "Show Modal" case .showPanelModal: return "Show Panel Modal" + case .showPanelModal2: return "Show Panel Modal 2" case .showMultiPanelModal: return "Show Multi Panel Modal" case .showOnWindow: return "Show Panel over Window" case .showPanelInSheetModal: return "Show Panel in Sheet Modal" @@ -81,10 +83,11 @@ extension UseCase { case .trackingTextView: return .storyboard("ConsoleViewController") // Storyboard only case .showDetail: return .storyboard(String(describing: DetailViewController.self)) case .showModal: return .storyboard(String(describing: ModalViewController.self)) + case .showPanelModal: return .viewController(DebugTableViewController()) + case .showPanelModal2: return .storyboard("ConsoleViewController") case .showMultiPanelModal: return .viewController(DebugTableViewController()) case .showOnWindow: return .viewController(DebugTableViewController()) case .showPanelInSheetModal: return .viewController(DebugTableViewController()) - case .showPanelModal: return .viewController(DebugTableViewController()) case .showTabBar: return .storyboard(String(describing: TabBarViewController.self)) case .showPageView: return .viewController(DebugTableViewController()) case .showPageContentView: return .viewController(DebugTableViewController()) diff --git a/Examples/Samples/Sources/UseCases/UseCaseController.swift b/Examples/Samples/Sources/UseCases/UseCaseController.swift index 4e1ac686..f1a52d61 100644 --- a/Examples/Samples/Sources/UseCases/UseCaseController.swift +++ b/Examples/Samples/Sources/UseCases/UseCaseController.swift @@ -178,6 +178,14 @@ extension UseCaseController { mainVC.present(fpc, animated: true, completion: nil) + case .showPanelModal2: + let fpc = FloatingPanelController() + fpc.set(contentViewController: contentVC) + fpc.delegate = self + fpc.track(scrollView: (contentVC as? DebugTextViewController)!.textView) + + mainVC.present(fpc, animated: true, completion: nil) + case .showMultiPanelModal: let fpc = MultiPanelController() mainVC.present(fpc, animated: true, completion: nil) @@ -202,10 +210,10 @@ extension UseCaseController { fpc.set(contentViewController: contentVC) fpc.delegate = self - let apprearance = SurfaceAppearance() - apprearance.cornerRadius = 38.5 - apprearance.shadows = [] - fpc.surfaceView.appearance = apprearance + let appearance = SurfaceAppearance() + appearance.cornerRadius = 38.5 + appearance.shadows = [] + fpc.surfaceView.appearance = appearance fpc.isRemovalInteractionEnabled = true let mvc = UIViewController() @@ -435,6 +443,8 @@ extension UseCaseController: FloatingPanelControllerDelegate { return newCollection.verticalSizeClass == .compact ? RemovablePanelLandscapeLayout() : RemovablePanelLayout() case .showIntrinsicView: return IntrinsicPanelLayout() + case .showPanelModal2: + return ModalPanelLayout2() case .showPanelModal: if vc != mainPanelVC && vc != detailPanelVC { return ModalPanelLayout() diff --git a/FloatingPanel.podspec b/FloatingPanel.podspec index 85742e92..e0dfc4d7 100644 --- a/FloatingPanel.podspec +++ b/FloatingPanel.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "FloatingPanel" - s.version = "2.8.4" + s.version = "2.8.8" 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. @@ -9,7 +9,7 @@ The new interface displays the related contents and utilities in parallel as a u DESC s.homepage = "https://github.com/scenee/FloatingPanel" s.author = "Shin Yamamoto" - s.social_media_url = "https://twitter.com/scenee" + s.social_media_url = "https://x.com/scenee" s.platform = :ios, "11.0" s.source = { :git => "https://github.com/scenee/FloatingPanel.git", :tag => s.version.to_s } diff --git a/README.md b/README.md index f86f655d..941a403f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 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.4/documentation/floatingpanel) for more details. +Please see also [the API reference@SPI](https://swiftpackageindex.com/scenee/FloatingPanel/2.8.8/documentation/floatingpanel) for more details. ![Maps](https://github.com/SCENEE/FloatingPanel/blob/master/assets/maps.gif) ![Stocks](https://github.com/SCENEE/FloatingPanel/blob/master/assets/stocks.gif) diff --git a/Sources/Controller.swift b/Sources/Controller.swift index ca29df14..af5af86f 100644 --- a/Sources/Controller.swift +++ b/Sources/Controller.swift @@ -265,6 +265,16 @@ open class FloatingPanelController: UIViewController { private var _contentViewController: UIViewController? + public var isAnimating: Bool = false { + didSet { + if isAnimating { + floatingPanel.lockAllScrollViews() + } else { + floatingPanel.unlockAllScrollViews() + } + } + } + private(set) var floatingPanel: Core! private var preSafeAreaInsets: UIEdgeInsets = .zero // Capture the latest one private var safeAreaInsetsObservation: NSKeyValueObservation? diff --git a/Sources/Core.swift b/Sources/Core.swift index 9188a7d2..28ebe98c 100644 --- a/Sources/Core.swift +++ b/Sources/Core.swift @@ -1,6 +1,7 @@ // Copyright 2018-Present Shin Yamamoto. All rights reserved. MIT license. import UIKit +import WebKit import os.log /// @@ -20,24 +21,64 @@ class Core: NSObject, UIGestureRecognizerDelegate { let layoutAdapter: LayoutAdapter let behaviorAdapter: BehaviorAdapter - weak var scrollView: UIScrollView? { - didSet { - oldValue?.panGestureRecognizer.removeTarget(self, action: nil) - scrollView?.panGestureRecognizer.addTarget(self, action: #selector(handle(panGesture:))) + // _scrollView is the main UIScrollView + weak var _scrollView: UIScrollView? = nil + // innerScrollView is used to hold WKWebView's inner scroll view. It contains correct content-offset. + private weak var _innerScrollView: UIScrollView? = nil + var scrollView: UIScrollView? { + get { + return _innerScrollView ?? _scrollView + } + set { + _scrollView?.panGestureRecognizer.removeTarget(self, action: nil) + newValue?.panGestureRecognizer.addTarget(self, action: #selector(handle(panGesture:))) if let cur = scrollView { - if oldValue == nil { + if _scrollView == nil { initialScrollOffset = cur.contentOffset scrollBounce = cur.bounces scrollIndictorVisible = cur.showsVerticalScrollIndicator } scrollLocked = false } else { - if let pre = oldValue { + if let pre = _scrollView { pre.isDirectionalLockEnabled = false pre.bounces = scrollBounce pre.showsVerticalScrollIndicator = scrollIndictorVisible } } + _scrollView = newValue + } + } + + // Called to find inner scroll view to handle WKWebView scroll views. + private func updateInnerScrollView() { + guard let _scrollView else { + return + } + // Function to find UIScrollViews in a web-view's subviews + var foundScrollViews = [UIScrollView]() + func findScrollView(in view: UIView, isParent: Bool = false) { + if isParent { + foundScrollViews = [] + } + if let scrollView = view as? UIScrollView, !isParent { + foundScrollViews.append(scrollView) + } + for subview in view.subviews { + findScrollView(in: subview) + } + } + findScrollView(in: _scrollView, isParent: true) + let newScrollView = foundScrollViews.last + if (newScrollView != _innerScrollView) { + // Check if existing innerScrollView was locked + _innerScrollView = newScrollView + let wasInnerLocked = !lockedInnerScrollViews.isEmpty + if wasInnerLocked { + // Lock the new one also. + lockAllScrollViews(isInnerScrollViewUpdated: true) + } + _innerScrollView?.panGestureRecognizer.addTarget(self, action: #selector(handle(panGesture:))) } } @@ -71,10 +112,10 @@ class Core: NSObject, UIGestureRecognizerDelegate { var removalVector: CGVector = .zero // Scroll handling - private var initialScrollOffset: CGPoint = .zero + private var initialScrollOffset: CGPoint? private var scrollBounce = false private var scrollIndictorVisible = false - private var scrollBounceThreshold: CGFloat = -30.0 + private var scrollBounceThreshold: CGFloat = -30 private var scrollLocked = false // MARK: - Interface @@ -136,6 +177,7 @@ class Core: NSObject, UIGestureRecognizerDelegate { interruptAnimationIfNeeded() if animated { + lockAllScrollViews() let updateScrollView: () -> Void = { [weak self] in guard let self = self else { return } if self.isScrollable(state: self.state), 0 == self.layoutAdapter.offset(from: self.state) { @@ -143,6 +185,7 @@ class Core: NSObject, UIGestureRecognizerDelegate { } else { self.lockScrollView() } + unlockAllScrollViews() } let animator: UIViewPropertyAnimator @@ -292,7 +335,9 @@ class Core: NSObject, UIGestureRecognizerDelegate { } // all gestures of the tracking scroll view should be recognized in parallel // and handle them in self.handle(panGesture:) - return scrollView?.gestureRecognizers?.contains(otherGestureRecognizer) ?? false + return (_scrollView?.gestureRecognizers?.contains(otherGestureRecognizer) ?? false) || + ((_innerScrollView?.contentSize.width ?? 0) + 32 >= _scrollView?.contentSize.width ?? 0 && + _innerScrollView?.gestureRecognizers?.contains(otherGestureRecognizer) ?? false) default: // Should recognize tap/long press gestures in parallel when the surface view is at an anchor position. let adapterY = layoutAdapter.position(for: state) @@ -323,31 +368,44 @@ class Core: NSObject, UIGestureRecognizerDelegate { public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool { guard gestureRecognizer == panGestureRecognizer else { return false } - // Should begin the pan gesture without waiting for the tracking scroll view's gestures. - // `scrollView.gestureRecognizers` can contains the following gestures - // * UIScrollViewDelayedTouchesBeganGestureRecognizer - // * UIScrollViewPanGestureRecognizer (scrollView.panGestureRecognizer) - // * _UIDragAutoScrollGestureRecognizer - // * _UISwipeActionPanGestureRecognizer - // * UISwipeDismissalGestureRecognizer - if let scrollView = scrollView { - // On short contents scroll, `_UISwipeActionPanGestureRecognizer` blocks - // the panel's pan gesture if not returns false - if let scrollGestureRecognizers = scrollView.gestureRecognizers, - scrollGestureRecognizers.contains(otherGestureRecognizer) { - switch otherGestureRecognizer { - case scrollView.panGestureRecognizer: - if surfaceView.grabberAreaContains(gestureRecognizer.location(in: surfaceView)) { - return false - } + // Workaround to fix unexpected scrolling issues on iOS 17.4 due to WKWebView gesture changes! + if _scrollView?.superview is WKWebView { + if otherGestureRecognizer is UIPanGestureRecognizer { + updateInnerScrollView() + } + } + + for sv in [_innerScrollView, _scrollView] { + // Should begin the pan gesture without waiting for the tracking scroll view's gestures. + // `scrollView.gestureRecognizers` can contains the following gestures + // * UIScrollViewDelayedTouchesBeganGestureRecognizer + // * UIScrollViewPanGestureRecognizer (scrollView.panGestureRecognizer) + // * _UIDragAutoScrollGestureRecognizer + // * _UISwipeActionPanGestureRecognizer + // * UISwipeDismissalGestureRecognizer + if let scrollView = sv { + // On short contents scroll, `_UISwipeActionPanGestureRecognizer` blocks + // the panel's pan gesture if not returns false + + if let scrollGestureRecognizers = scrollView.gestureRecognizers, + scrollGestureRecognizers.contains(otherGestureRecognizer) { + switch otherGestureRecognizer { + case scrollView.panGestureRecognizer: + if surfaceView.grabberAreaContains(gestureRecognizer.location(in: surfaceView)) { + return false + } - guard isScrollable(state: state) else { return false } + guard isScrollable(state: state) else { return false } - // The condition where offset > 0 must not be included here. Because it will stop recognizing - // the panel pan gesture if a user starts scrolling content from an offset greater than 0. - return allowScrollPanGesture(of: scrollView) { offset in offset <= scrollBounceThreshold } - default: - return false + // The condition where offset > 0 must not be included here. Because it will stop recognizing + // the panel pan gesture if a user starts scrolling content from an offset greater than 0. + if let _innerScrollView, (allowScrollPanGesture(of: _innerScrollView) { offset in offset <= scrollBounceThreshold }) { + return true + } + return allowScrollPanGesture(of: _scrollView!) { offset in offset <= scrollBounceThreshold } + default: + return false + } } } } @@ -387,10 +445,11 @@ class Core: NSObject, UIGestureRecognizerDelegate { ownerVC?.remove() } + private var isScrollingHorizontally = false @objc func handle(panGesture: UIPanGestureRecognizer) { switch panGesture { - case scrollView?.panGestureRecognizer: - guard let scrollView = scrollView else { return } + case scrollView?.panGestureRecognizer, _innerScrollView?.panGestureRecognizer: + let scrollView = panGesture == scrollView?.panGestureRecognizer ? _scrollView! : _innerScrollView! let velocity = value(of: panGesture.velocity(in: panGesture.view)) let location = panGesture.location(in: surfaceView) @@ -411,7 +470,7 @@ class Core: NSObject, UIGestureRecognizerDelegate { if insideMostExpandedAnchor { // Prevent scrolling if needed - if isScrollable(state: state) { + if isScrollable(state: state), let initialScrollOffset = initialScrollOffset { if interactionInProgress { os_log(msg, log: devLog, type: .debug, "settle offset -- \(value(of: initialScrollOffset))") // Return content offset to initial offset to prevent scrolling @@ -429,7 +488,7 @@ class Core: NSObject, UIGestureRecognizerDelegate { stopScrolling(at: initialScrollOffset) } } - } else { + } else if let initialScrollOffset = initialScrollOffset { // Return content offset to initial offset to prevent scrolling stopScrolling(at: initialScrollOffset) } @@ -471,7 +530,8 @@ class Core: NSObject, UIGestureRecognizerDelegate { } if isScrollable(state: state) { // Adjust a small gap of the scroll offset just after swiping down starts in the grabber area. - if surfaceView.grabberAreaContains(location), surfaceView.grabberAreaContains(initialLocation) { + if surfaceView.grabberAreaContains(location), surfaceView.grabberAreaContains(initialLocation), + let initialScrollOffset = initialScrollOffset { stopScrolling(at: initialScrollOffset) } } @@ -499,7 +559,8 @@ class Core: NSObject, UIGestureRecognizerDelegate { } } // Adjust a small gap of the scroll offset just before swiping down starts in the grabber area, - if surfaceView.grabberAreaContains(location), surfaceView.grabberAreaContains(initialLocation) { + if surfaceView.grabberAreaContains(location), surfaceView.grabberAreaContains(initialLocation), + let initialScrollOffset = initialScrollOffset { stopScrolling(at: initialScrollOffset) } } @@ -527,6 +588,7 @@ class Core: NSObject, UIGestureRecognizerDelegate { interruptAnimationIfNeeded() if panGesture.state == .began { + isScrollingHorizontally = abs(velocity.x) > abs(velocity.y) panningBegan(at: location) return } @@ -552,6 +614,8 @@ class Core: NSObject, UIGestureRecognizerDelegate { allowsRubberBanding: behaviorAdapter.allowsRubberBanding(for:)) } panningEnd(with: translation, velocity: velocity) + // Reset the scroll direction lock + isScrollingHorizontally = false default: break } @@ -605,10 +669,15 @@ class Core: NSObject, UIGestureRecognizerDelegate { } } + // If is scrolling horizontally, consider it as a swipe back! + if isScrollingHorizontally { + return true + } + guard isScrollable(state: state), // When not top most(i.e. .full), don't scroll. interactionInProgress == false, // When interaction already in progress, don't scroll. - 0 == layoutAdapter.offset(from: state), + abs(layoutAdapter.offset(from: state)) < 1, // Indistinguishably close to an anchor point. !surfaceView.grabberAreaContains(initialLocation) // When the initial point is within grabber area, don't scroll else { return false @@ -626,7 +695,7 @@ class Core: NSObject, UIGestureRecognizerDelegate { return true } case .bottom, .right: - if offset > 0.0 { + if offset > 0.0 || (scrollView == _innerScrollView && offset == 0) { return true } if velocity <= 0, offset < 0.0 { @@ -736,7 +805,11 @@ class Core: NSObject, UIGestureRecognizerDelegate { let distToHidden = CGFloat(abs(currentPos - layoutAdapter.position(for: .hidden))) switch layoutAdapter.position { case .top, .bottom: - removalVector = (distToHidden != 0) ? CGVector(dx: 0.0, dy: velocity.y/distToHidden) : .zero + if isScrollingHorizontally { + removalVector = .zero + } else { + removalVector = (distToHidden != 0) ? CGVector(dx: 0.0, dy: velocity.y/distToHidden) : .zero + } case .left, .right: removalVector = (distToHidden != 0) ? CGVector(dx: velocity.x/distToHidden, dy: 0.0) : .zero } @@ -847,7 +920,7 @@ class Core: NSObject, UIGestureRecognizerDelegate { } else { initialScrollOffset = scrollView.contentOffset } - os_log(msg, log: devLog, type: .debug, "initial scroll offset -- \(initialScrollOffset)") + os_log(msg, log: devLog, type: .debug, "initial scroll offset -- \(optional: initialScrollOffset)") } initialTranslation = translation @@ -894,17 +967,6 @@ class Core: NSObject, UIGestureRecognizerDelegate { return true } - func endWithoutAttraction(_ target: FloatingPanelState) { - self.state = target - self.updateLayout(to: target) - self.unlockScrollView() - // The `floatingPanelDidEndDragging(_:willAttract:)` must be called after the state property changes. - // This allows library users to get the correct state in the delegate method. - if let vc = ownerVC { - vc.delegate?.floatingPanelDidEndDragging?(vc, willAttract: false) - } - } - private func startAttraction(to state: FloatingPanelState, with velocity: CGPoint, completion: @escaping (() -> Void)) { os_log(msg, log: devLog, type: .debug, "startAnimation to \(state) -- velocity = \(value(of: velocity))") guard let vc = ownerVC else { return } @@ -934,8 +996,8 @@ class Core: NSObject, UIGestureRecognizerDelegate { self.backdropView.alpha = self.getBackdropAlpha(at: current, with: translation) // Pin the offset of the tracking scroll view while moving by this animator - if let scrollView = self.scrollView { - self.stopScrolling(at: self.initialScrollOffset) + if let scrollView = self.scrollView, let initialScrollOffset = self.initialScrollOffset { + self.stopScrolling(at: initialScrollOffset) os_log(msg, log: devLog, type: .debug, "move -- pinning scroll offset = \(scrollView.contentOffset)") } @@ -959,6 +1021,12 @@ class Core: NSObject, UIGestureRecognizerDelegate { self.isAttracting = false self.moveAnimator = nil + // We need to reset `initialScrollOffset` because the scroll offset can become unexpected + // under the following circumstances: + // 1. The scroll offset changes while the panel does not move. + // 2. The panel is then moved using `move(to:animate:completion:)`. + self.initialScrollOffset = nil + if let vc = ownerVC { vc.delegate?.floatingPanelDidEndAttracting?(vc) } @@ -981,6 +1049,20 @@ class Core: NSObject, UIGestureRecognizerDelegate { } } + func endWithoutAttraction(_ target: FloatingPanelState) { + // See comments in `endAttraction` + self.initialScrollOffset = nil + + self.state = target + self.updateLayout(to: target) + self.unlockScrollView() + // The `floatingPanelDidEndDragging(_:willAttract:)` must be called after the state property changes. + // This allows library users to get the correct state in the delegate method. + if let vc = ownerVC { + vc.delegate?.floatingPanelDidEndDragging?(vc, willAttract: false) + } + } + func value(of point: CGPoint) -> CGFloat { return layoutAdapter.position.mainLocation(point) } @@ -1077,7 +1159,7 @@ class Core: NSObject, UIGestureRecognizerDelegate { } private func lockScrollView(strict: Bool = false) { - guard let scrollView = scrollView else { return } + guard let scrollView = _scrollView else { return } if scrollLocked { os_log(msg, log: devLog, type: .debug, "Already scroll locked") @@ -1109,7 +1191,7 @@ class Core: NSObject, UIGestureRecognizerDelegate { } private func unlockScrollView() { - guard let scrollView = scrollView else { return } + guard let scrollView = _scrollView else { return } if !scrollLocked { os_log(msg, log: devLog, type: .debug, "Already scroll unlocked.") return @@ -1127,6 +1209,32 @@ class Core: NSObject, UIGestureRecognizerDelegate { } } + // Locks all the scrollviews (Used to lock when animating) + private var lockedInnerScrollViews = [UIScrollView]() + func lockAllScrollViews(isInnerScrollViewUpdated: Bool = false) { + // First update inner scroll view + if !isInnerScrollViewUpdated { + updateInnerScrollView() + } + // Now lock the new inner scroll view + guard let _innerScrollView else {return} + if !lockedInnerScrollViews.contains(_innerScrollView) { + _innerScrollView.isUserInteractionEnabled = false + lockedInnerScrollViews.append(_innerScrollView) + _scrollView?.isUserInteractionEnabled = false + } + } + func unlockAllScrollViews() { + if lockedInnerScrollViews.isEmpty { + return + } + _scrollView?.isUserInteractionEnabled = true + for _innerScrollView in lockedInnerScrollViews { + _innerScrollView.isUserInteractionEnabled = true + } + lockedInnerScrollViews.removeAll() + } + private var shouldLooselyLockScrollView: Bool { if surfaceView.frame == .zero { return false @@ -1178,7 +1286,7 @@ class Core: NSObject, UIGestureRecognizerDelegate { func isScrollable(state: FloatingPanelState) -> Bool { guard let scrollView = scrollView else { return false } - if let fpc = ownerVC, + if let fpc = ownerVC, let scrollable = fpc.delegate?.floatingPanel?(fpc, shouldAllowToScroll: scrollView, in: state) { return scrollable @@ -1186,16 +1294,21 @@ class Core: NSObject, UIGestureRecognizerDelegate { return state == layoutAdapter.mostExpandedState } - /// Adjust content inset of the tracking scroll view if the controller's - /// `contentInsetAdjustmentBehavior` is `.always` and its `contentMode` is `.static`. - /// if its content is scrollable, the content might not be fully visible on `.half` - /// state, for example. Therefore the content inset needs to adjust to display the - /// full content. + // Adjusts content inset of the tracking scroll view when the following conditions are met: + // - The controller's `contentInsetAdjustmentBehavior` is `.always` + // - Its `contentMode` is `.static` + // - Its content is scrollable + // This ensures that the content remains fully visible in intermediate states like `.half`, + // by using `UIScrollView.safeAreaInsets` and the panel's current position. + // This method must not be invoked in the fully expanded state, as it may lead to unexpected + // behavior under the top safe area (i.e., the status bar). func adjustScrollContentInsetIfNeeded() { guard let fpc = ownerVC, let scrollView = scrollView, - fpc.contentInsetAdjustmentBehavior == .always + fpc.contentInsetAdjustmentBehavior == .always, + fpc.state != layoutAdapter.mostExpandedState, + isScrollable(state: fpc.state) else { return } switch fpc.contentMode { diff --git a/Sources/Info.plist b/Sources/Info.plist index 32767e9e..173b005c 100644 --- a/Sources/Info.plist +++ b/Sources/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 2.8.4 + 2.8.8 CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/Sources/LayoutAnchoring.swift b/Sources/LayoutAnchoring.swift index 17e1aa5d..83388738 100644 --- a/Sources/LayoutAnchoring.swift +++ b/Sources/LayoutAnchoring.swift @@ -17,7 +17,7 @@ import UIKit /// positioning. /// /// - Parameters: - /// - absoluteOffset: An absolute offset to attach the panel from the edge. + /// - absoluteInset: An absolute distance to attach the panel from the specified edge. /// - edge: Specify the edge of ``FloatingPanelController``'s view. This is the staring point of the offset. /// - referenceGuide: The rectangular area to lay out the content. If it's set to `.safeArea`, the panel content lays out inside the safe area of its ``FloatingPanelController``'s view. @objc public init(absoluteInset: CGFloat, edge: FloatingPanelReferenceEdge, referenceGuide: FloatingPanelLayoutReferenceGuide) { @@ -34,7 +34,7 @@ import UIKit /// 1.0 represents a distance to the opposite edge. /// /// - Parameters: - /// - fractionalOffset: A fractional value of the size of ``FloatingPanelController``'s view to attach the panel from the edge. + /// - fractionalInset: A fractional value of the size of ``FloatingPanelController``'s view to attach the panel from the specified edge. /// - edge: Specify the edge of ``FloatingPanelController``'s view. This is the staring point of the offset. /// - referenceGuide: The rectangular area to lay out the content. If it's set to `.safeArea`, the panel content lays out inside the safe area of its ``FloatingPanelController``'s view. @objc public init(fractionalInset: CGFloat, edge: FloatingPanelReferenceEdge, referenceGuide: FloatingPanelLayoutReferenceGuide) { @@ -115,8 +115,8 @@ public extension FloatingPanelLayoutAnchor { /// - Parameters: /// - absoluteOffset: An absolute offset from the content size in the main dimension(i.e. y axis for a bottom panel) to attach the panel. /// - referenceGuide: The rectangular area to lay out the content. If it's set to `.safeArea`, the panel content lays out inside the safe area of its ``FloatingPanelController``'s view. - @objc public init(absoluteOffset offset: CGFloat, referenceGuide: FloatingPanelLayoutReferenceGuide = .safeArea) { - self.offset = offset + @objc public init(absoluteOffset: CGFloat, referenceGuide: FloatingPanelLayoutReferenceGuide = .safeArea) { + self.offset = absoluteOffset self.referenceGuide = referenceGuide self.isAbsolute = true } @@ -129,8 +129,8 @@ public extension FloatingPanelLayoutAnchor { /// - Parameters: /// - fractionalOffset: A fractional offset of the content size in the main dimension(i.e. y axis for a bottom panel) to attach the panel. /// - referenceGuide: The rectangular area to lay out the content. If it's set to `.safeArea`, the panel content lays out inside the safe area of its ``FloatingPanelController``'s view. - @objc public init(fractionalOffset offset: CGFloat, referenceGuide: FloatingPanelLayoutReferenceGuide = .safeArea) { - self.offset = offset + @objc public init(fractionalOffset: CGFloat, referenceGuide: FloatingPanelLayoutReferenceGuide = .safeArea) { + self.offset = fractionalOffset self.referenceGuide = referenceGuide self.isAbsolute = false } @@ -177,12 +177,12 @@ public extension FloatingPanelIntrinsicLayoutAnchor { /// /// - Warning: If ``contentBoundingGuide`` is set to none, the panel may expand out of the screen size, depending on the intrinsic size of its content. @objc public init( - absoluteOffset offset: CGFloat, + absoluteOffset: CGFloat, contentLayout: UILayoutGuide, referenceGuide: FloatingPanelLayoutReferenceGuide, contentBoundingGuide: FloatingPanelLayoutContentBoundingGuide = .none ) { - self.offset = offset + self.offset = absoluteOffset self.contentLayoutGuide = contentLayout self.referenceGuide = referenceGuide self.contentBoundingGuide = contentBoundingGuide @@ -204,12 +204,12 @@ public extension FloatingPanelIntrinsicLayoutAnchor { /// /// - Warning: If ``contentBoundingGuide`` is set to none, the panel may expand out of the screen size, depending on the intrinsic size of its content. @objc public init( - fractionalOffset offset: CGFloat, + fractionalOffset: CGFloat, contentLayout: UILayoutGuide, referenceGuide: FloatingPanelLayoutReferenceGuide, contentBoundingGuide: FloatingPanelLayoutContentBoundingGuide = .none ) { - self.offset = offset + self.offset = fractionalOffset self.contentLayoutGuide = contentLayout self.referenceGuide = referenceGuide self.contentBoundingGuide = contentBoundingGuide diff --git a/Sources/Logging.swift b/Sources/Logging.swift index 5c4f3c0f..e813254b 100644 --- a/Sources/Logging.swift +++ b/Sources/Logging.swift @@ -15,3 +15,14 @@ struct Logging { static let category = "FloatingPanel" private init() {} } + +extension String.StringInterpolation { + mutating func appendInterpolation(optional: T?, defaultValue: String = "nil") { + switch optional { + case let value?: + appendLiteral(String(describing: value)) + case nil: + appendLiteral(defaultValue) + } + } +} diff --git a/Sources/Transitioning.swift b/Sources/Transitioning.swift index 88ab2348..c4bc7da8 100644 --- a/Sources/Transitioning.swift +++ b/Sources/Transitioning.swift @@ -19,10 +19,13 @@ class ModalTransition: NSObject, UIViewControllerTransitioningDelegate { } class PresentationController: UIPresentationController { + private var passthroughView: PassthroughView? + override func presentationTransitionWillBegin() { // Must call here even if duplicating on in containerViewWillLayoutSubviews() // Because it let the panel present correctly with the presentation animation addFloatingPanel() + addPassthroughView() } override func presentationTransitionDidEnd(_ completed: Bool) { @@ -40,6 +43,7 @@ class PresentationController: UIPresentationController { } fpc.view.removeFromSuperview() } + passthroughView?.removeFromSuperview() } override func containerViewWillLayoutSubviews() { @@ -62,12 +66,22 @@ class PresentationController: UIPresentationController { // Forward touch events to the presenting view controller (fpc.view as? PassthroughView)?.eventForwardingView = presentingViewController.view + passthroughView?.eventForwardingView = presentingViewController.view } @objc func handleBackdrop(tapGesture: UITapGestureRecognizer) { presentedViewController.dismiss(animated: true, completion: nil) } + private func addPassthroughView() { + guard let containerView = self.containerView else { return } + + passthroughView = PassthroughView() + passthroughView?.frame = containerView.bounds + passthroughView?.autoresizingMask = [.flexibleWidth, .flexibleHeight] + containerView.addSubview(passthroughView!) + } + private func addFloatingPanel() { guard let containerView = self.containerView, @@ -84,7 +98,7 @@ class ModalPresentTransition: NSObject, UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { guard let fpc = transitionContext?.viewController(forKey: .to) as? FloatingPanelController - else { fatalError()} + else { return 0.0 } let animator = fpc.animatorForPresenting(to: fpc.layout.initialState) return TimeInterval(animator.duration) @@ -119,7 +133,7 @@ class ModalDismissTransition: NSObject, UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { guard let fpc = transitionContext?.viewController(forKey: .from) as? FloatingPanelController - else { fatalError()} + else { return 0.0 } let animator = fpc.animatorForDismissing(with: .zero) return TimeInterval(animator.duration) diff --git a/Tests/CoreTests.swift b/Tests/CoreTests.swift index 11201949..8a18dd69 100644 --- a/Tests/CoreTests.swift +++ b/Tests/CoreTests.swift @@ -863,11 +863,18 @@ class CoreTests: XCTestCase { customSafeAreaInsets } } + class PanelDelegate: FloatingPanelControllerDelegate { + func floatingPanel(_ fpc: FloatingPanelController, shouldAllowToScroll scrollView: UIScrollView, in state: FloatingPanelState) -> Bool { + return state == .full || state == .half + } + } + let delegate = PanelDelegate() do { let scrollView = CustomScrollView() scrollView.customSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: 34, right: 0) let fpc = FloatingPanelController() + fpc.delegate = delegate fpc.track(scrollView: scrollView) fpc.layout = FloatingPanelBottomLayout() fpc.contentInsetAdjustmentBehavior = .always @@ -894,6 +901,7 @@ class CoreTests: XCTestCase { let scrollView = CustomScrollView() scrollView.customSafeAreaInsets = UIEdgeInsets(top: 91, left: 0, bottom: 0, right: 0) let fpc = FloatingPanelController() + fpc.delegate = delegate fpc.track(scrollView: scrollView) fpc.layout = FloatingPanelTopPositionedLayout() fpc.contentInsetAdjustmentBehavior = .always @@ -916,6 +924,68 @@ class CoreTests: XCTestCase { } } + func test_adjustScrollContentInsetIfNeeded_normal() { + class CustomScrollView: UIScrollView { + var customSafeAreaInsets: UIEdgeInsets = .zero + override var safeAreaInsets: UIEdgeInsets { + customSafeAreaInsets + } + } + do { + let scrollView = CustomScrollView() + scrollView.customSafeAreaInsets = UIEdgeInsets(top: 42, left: 0, bottom: 34, right: 0) + let fpc = FloatingPanelController() + fpc.track(scrollView: scrollView) + fpc.layout = FloatingPanelBottomLayout() + fpc.contentInsetAdjustmentBehavior = .always + fpc.contentMode = .static + fpc.showForTest() + + fpc.move(to: .half, animated: false) + fpc.floatingPanel.adjustScrollContentInsetIfNeeded() + + XCTAssertEqual( + scrollView.contentInset, + UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + ) + + fpc.move(to: .full, animated: false) + fpc.floatingPanel.adjustScrollContentInsetIfNeeded() + XCTAssertEqual( + scrollView.contentInset, + UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + ) + } + } + + func test_initial_scroll_offset_reset() { + let fpc = FloatingPanelController() + let scrollView = UIScrollView() + fpc.layout = FloatingPanelBottomLayout() + fpc.track(scrollView: scrollView) + fpc.showForTest() + + fpc.move(to: .full, animated: false) + + fpc.panGestureRecognizer.state = .began + fpc.floatingPanel.handle(panGesture: fpc.panGestureRecognizer) + + fpc.panGestureRecognizer.state = .cancelled + fpc.floatingPanel.handle(panGesture: fpc.panGestureRecognizer) + + waitRunLoop(secs: 1.0) + + let expect = CGPoint(x: 0, y: 100) + + scrollView.setContentOffset(expect, animated: false) + + fpc.move(to: .half, animated: true) + + waitRunLoop(secs: 1.0) + + XCTAssertEqual(expect, scrollView.contentOffset) + } + func test_handleGesture_endWithoutAttraction() throws { class Delegate: FloatingPanelControllerDelegate { var willAttract: Bool?