diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97dc9f03..dd0ccf23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,9 @@ jobs: fail-fast: false matrix: include: + - swift: "5" + xcode: "26.2" + runs-on: macos-15 - swift: "5" xcode: "16.4" runs-on: macos-15 @@ -37,8 +40,8 @@ jobs: fail-fast: false matrix: include: - - os: "26.0.1" - xcode: "26.0.1" + - os: "26.2" + xcode: "26.2" sim: "iPhone 16 Pro" parallel: NO # Stop random test job failures runs-on: macos-15 @@ -66,7 +69,7 @@ jobs: example: runs-on: macos-15 env: - DEVELOPER_DIR: /Applications/Xcode_26.0.1.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer strategy: fail-fast: false matrix: @@ -93,21 +96,21 @@ jobs: strategy: fail-fast: false matrix: - xcode: ["26.0.1", "16.4", "15.4"] + xcode: ["26.2", "16.4", "15.4"] platform: [iphoneos, iphonesimulator] arch: [x86_64, arm64] exclude: - platform: iphoneos arch: x86_64 include: - # 26.0.1 + # 26.x - platform: iphoneos - xcode: "26.0.1" - sys: "ios26.0" + xcode: "26.2" + sys: "ios26.2" runs-on: macos-15 - platform: iphonesimulator - xcode: "26.0.1" - sys: "ios26.0-simulator" + xcode: "26.2" + sys: "ios26.2-simulator" runs-on: macos-15 # 18.5 - platform: iphoneos @@ -138,7 +141,7 @@ jobs: cocoapods: runs-on: macos-15 env: - DEVELOPER_DIR: /Applications/Xcode_26.0.1.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer steps: - uses: actions/checkout@v4 - name: "CocoaPods: pod lib lint" diff --git a/Examples/SamplesSwiftUI/SamplesSwiftUI/UseCases/MainView.swift b/Examples/SamplesSwiftUI/SamplesSwiftUI/UseCases/MainView.swift index 35412bb3..7ca5e8a9 100644 --- a/Examples/SamplesSwiftUI/SamplesSwiftUI/UseCases/MainView.swift +++ b/Examples/SamplesSwiftUI/SamplesSwiftUI/UseCases/MainView.swift @@ -15,6 +15,7 @@ struct MainView: View { @State private var panelLayout: FloatingPanelLayout? = MyFloatingPanelLayout() @State private var panelState: FloatingPanelState? @State private var selectedContent: CardContent = .list + @State private var lastEvent: MyPanelCoordinator.Event? var body: some View { ZStack { @@ -28,6 +29,9 @@ struct MainView: View { } } .pickerStyle(.segmented) + + Text("Last event: \(lastEvent?.rawValue ?? "None")") + Button("Move to full") { withAnimation(.interactiveSpring) { panelState = .full @@ -52,7 +56,8 @@ struct MainView: View { } } .floatingPanel( - coordinator: MyPanelCoordinator.self + coordinator: MyPanelCoordinator.self, + onEvent: onEvent ) { proxy in switch selectedContent { case .list: @@ -80,11 +85,18 @@ struct MainView: View { Logger().debug("Panel state changed: \(newValue ?? .hidden)") } } + + func onEvent(_ event: MyPanelCoordinator.Event) { + lastEvent = event + } } // A custom coordinator object which handles panel context updates and setting up `FloatingPanelControllerDelegate` methods class MyPanelCoordinator: FloatingPanelCoordinator { - enum Event {} + enum Event: String { + case willBeginDragging + case didEndAttracting + } let action: (Event) -> Void let proxy: FloatingPanelProxy @@ -115,6 +127,14 @@ class MyPanelCoordinator: FloatingPanelCoordinator { } extension MyPanelCoordinator: FloatingPanelControllerDelegate { + func floatingPanelWillBeginDragging(_ fpc: FloatingPanelController) { + action(.willBeginDragging) + } + + func floatingPanelDidEndAttracting(_ fpc: FloatingPanelController) { + action(.didEndAttracting) + } + func floatingPanelDidChangeState(_ fpc: FloatingPanelController) { // NOTE: This timing is difference from one of the change of the binding value // to `floatingPanelState(_:)` modifier diff --git a/FloatingPanel.xcodeproj/project.pbxproj b/FloatingPanel.xcodeproj/project.pbxproj index 96e7c1de..0100dfa4 100644 --- a/FloatingPanel.xcodeproj/project.pbxproj +++ b/FloatingPanel.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 5404FC6D2F3D900E00BCC99B /* CoordinatorProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5404FC6B2F3D900E00BCC99B /* CoordinatorProxyTests.swift */; }; 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 */; }; @@ -58,6 +59,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 5404FC6B2F3D900E00BCC99B /* CoordinatorProxyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinatorProxyTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -121,6 +123,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5404FC6C2F3D900E00BCC99B /* SwiftUI */ = { + isa = PBXGroup; + children = ( + 5404FC6B2F3D900E00BCC99B /* CoordinatorProxyTests.swift */, + ); + path = SwiftUI; + sourceTree = ""; + }; 545DB9B72151169500CA77B8 = { isa = PBXGroup; children = ( @@ -169,6 +179,7 @@ 545DB9CE2151169500CA77B8 /* Tests */ = { isa = PBXGroup; children = ( + 5404FC6C2F3D900E00BCC99B /* SwiftUI */, 54A6B6B022968B530077F348 /* CoreTests.swift */, 545DB9CF2151169500CA77B8 /* ControllerTests.swift */, 547F7A9B2A6E946000303905 /* GestureTests.swift */, @@ -363,6 +374,7 @@ 542753C622C49A6E00D17955 /* LayoutTests.swift in Sources */, 54A6B6B82296A8520077F348 /* SurfaceViewTests.swift in Sources */, 546055BF2333C4740069F400 /* TestSupports.swift in Sources */, + 5404FC6D2F3D900E00BCC99B /* CoordinatorProxyTests.swift in Sources */, 547F7A9C2A6E946000303905 /* GestureTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Sources/SwiftUI/FloatingPanelView.swift b/Sources/SwiftUI/FloatingPanelView.swift index b11e2aeb..7fe220b6 100644 --- a/Sources/SwiftUI/FloatingPanelView.swift +++ b/Sources/SwiftUI/FloatingPanelView.swift @@ -161,6 +161,9 @@ extension FloatingPanelView { class FloatingPanelCoordinatorProxy { private let origin: any FloatingPanelCoordinator private var stateBinding: Binding + /// Tracks the last binding value seen by `update(state:)` to detect actual changes + /// vs. stale values from unrelated SwiftUI re-renders. + private var lastKnownBindingState: FloatingPanelState? private var subscriptions: Set = Set() @@ -249,7 +252,12 @@ extension FloatingPanelCoordinatorProxy { // Update the state of FloatingPanelController func update(state: FloatingPanelState?) { - guard let state = state else { return } + defer { lastKnownBindingState = state } + guard + let state = state, + state != lastKnownBindingState, + controller.state != state + else { return } controller.move(to: state, animated: false) } diff --git a/Tests/SwiftUI/CoordinatorProxyTests.swift b/Tests/SwiftUI/CoordinatorProxyTests.swift new file mode 100644 index 00000000..5036fd19 --- /dev/null +++ b/Tests/SwiftUI/CoordinatorProxyTests.swift @@ -0,0 +1,243 @@ +// Copyright 2018 the FloatingPanel authors. All rights reserved. MIT license. + +import SwiftUI +import XCTest + +@testable import FloatingPanel + +@available(iOS 14, *) +class CoordinatorProxyTests: XCTestCase { + override func setUp() {} + override func tearDown() {} + + // MARK: - Test doubles + + /// Records calls to `move(to:animated:completion:)` without performing real movement. + final class SpyFloatingPanelController: FloatingPanelController { + struct MoveCall { + let state: FloatingPanelState + let animated: Bool + } + fileprivate(set) var moveCalls: [MoveCall] = [] + + override func move( + to state: FloatingPanelState, + animated: Bool, + completion: (() -> Void)? = nil + ) { + moveCalls.append(MoveCall(state: state, animated: animated)) + super.move(to: state, animated: animated, completion: completion) + } + } + + /// A minimal `FloatingPanelCoordinator` that allows injecting a custom controller. + final class TestCoordinator: FloatingPanelCoordinator { + typealias Event = Void + let proxy: FloatingPanelProxy + let action: (Event) -> Void + + init(action: @escaping (Event) -> Void) { + self.action = action + self.proxy = FloatingPanelProxy(controller: FloatingPanelController()) + } + + /// Designated initializer for tests — accepts a pre-made controller. + init(controller: FloatingPanelController) { + self.action = { _ in } + self.proxy = FloatingPanelProxy(controller: controller) + } + + func setupFloatingPanel( + mainHostingController: UIHostingController
, + contentHostingController: UIHostingController + ) { + contentHostingController.view.backgroundColor = .clear + controller.set(contentViewController: contentHostingController) + controller.addPanel(toParent: mainHostingController, animated: false) + } + + func onUpdate( + context: UIViewControllerRepresentableContext + ) where Representable: UIViewControllerRepresentable {} + } + + // MARK: - Helpers + + private func makeProxy( + spy: SpyFloatingPanelController + ) -> FloatingPanelCoordinatorProxy { + let coordinator = TestCoordinator(controller: spy) + spy.showForTest() + var state: FloatingPanelState? = spy.state + let binding = Binding( + get: { state }, + set: { state = $0 } + ) + return FloatingPanelCoordinatorProxy( + coordinator: coordinator, + state: binding + ) + } +} + +// MARK: - Issue #680: update(state:) should skip move when state is unchanged + +/// Tests for `FloatingPanelCoordinatorProxy.update(state:)` — the internal bridge between +/// SwiftUI state bindings and `FloatingPanelController`. +@available(iOS 14, *) +extension CoordinatorProxyTests { + /// During a drag gesture, a delegate callback can trigger a SwiftUI re-render which + /// calls `update(state:)` with the current state. The fix ensures this redundant call + /// does NOT invoke `controller.move(to:animated:)`, preserving the interactive transition. + func test_updateState_skipsMove_whenStateIsUnchanged() { + let spy = SpyFloatingPanelController() + let proxy = makeProxy(spy: spy) + XCTAssertEqual(spy.state, .half) + + // Clear any move calls from setup + spy.moveCalls.removeAll() + + // update(state:) with the SAME state must not trigger move(to:) + proxy.update(state: .half) + + XCTAssertTrue( + spy.moveCalls.isEmpty, + "move(to:animated:) must not be called when the state is unchanged, " + + "but was called \(spy.moveCalls.count) time(s)" + ) + } + + func test_updateState_movesPanel_whenStateIsDifferent() { + let spy = SpyFloatingPanelController() + let proxy = makeProxy(spy: spy) + XCTAssertEqual(spy.state, .half) + + spy.moveCalls.removeAll() + + proxy.update(state: .full) + + XCTAssertEqual( + spy.moveCalls.count, 1, + "move(to:animated:) should be called exactly once" + ) + XCTAssertEqual(spy.moveCalls.first?.state, .full) + XCTAssertEqual(spy.moveCalls.first?.animated, false) + } + + func test_updateState_doesNothing_whenStateIsNil() { + let spy = SpyFloatingPanelController() + let proxy = makeProxy(spy: spy) + XCTAssertEqual(spy.state, .half) + + spy.moveCalls.removeAll() + + proxy.update(state: nil) + + XCTAssertTrue( + spy.moveCalls.isEmpty, + "move(to:animated:) must not be called when state is nil" + ) + } +} + +// MARK: - Stale binding guard: prevents revert when unrelated @State triggers re-render + +/// When `observeStateChanges()` defers a binding update via `Task { @MainActor }`, +/// a delegate callback (e.g. `didEndAttracting`) can trigger a SwiftUI re-render +/// before that deferred task runs. In that re-render, `update(state:)` receives +/// the OLD binding value. These tests verify that the proxy does not move the panel +/// back to the stale state. +@available(iOS 14, *) +extension CoordinatorProxyTests { + + /// Simulates the exact bug scenario: + /// 1. Panel internally reaches `.full` (via drag/attraction) + /// 2. A delegate callback causes a SwiftUI re-render + /// 3. `update(state:)` is called with the stale `.half` binding + /// The panel must NOT revert to `.half`. + func test_updateState_skipsMove_whenBindingIsStaleAfterInternalStateChange() { + let spy = SpyFloatingPanelController() + let proxy = makeProxy(spy: spy) + XCTAssertEqual(spy.state, .half) + + // Establish lastKnownBindingState = .half + proxy.update(state: .half) + spy.moveCalls.removeAll() + + // Simulate the panel internally moving to .full (e.g. user drag completed) + spy.move(to: .full, animated: false) + spy.moveCalls.removeAll() + + // Simulate stale re-render: a delegate callback updates an unrelated @State, + // causing updateUIViewController to be called with the OLD binding value (.half) + proxy.update(state: .half) + + XCTAssertTrue( + spy.moveCalls.isEmpty, + "Stale binding value must not cause move(to:), " + + "but was called \(spy.moveCalls.count) time(s)" + ) + XCTAssertEqual(spy.state, .full, "Panel must remain at .full") + } + + /// After a stale re-render, the deferred `Task` updates the binding to match + /// the controller's current state. This synced value must not trigger a redundant move. + func test_updateState_skipsRedundantMove_whenDeferredBindingSyncsToControllerState() { + let spy = SpyFloatingPanelController() + let proxy = makeProxy(spy: spy) + XCTAssertEqual(spy.state, .half) + + proxy.update(state: .half) + spy.moveCalls.removeAll() + + // Panel moves internally to .full + spy.move(to: .full, animated: false) + spy.moveCalls.removeAll() + + // Stale re-render (skipped by lastKnownBindingState guard) + proxy.update(state: .half) + XCTAssertTrue(spy.moveCalls.isEmpty) + + // Deferred Task finally updates the binding to .full. + // update(state:) is called again with the synced value. + proxy.update(state: .full) + + XCTAssertTrue( + spy.moveCalls.isEmpty, + "When binding syncs to controller's current state, no move should occur, " + + "but was called \(spy.moveCalls.count) time(s)" + ) + } + + /// After the stale-binding cycle resolves, a new intentional state change + /// (e.g. user taps "Move to tip") must still be applied. + func test_updateState_movesPanel_whenNewStateRequestedAfterStaleCycle() { + let spy = SpyFloatingPanelController() + let proxy = makeProxy(spy: spy) + XCTAssertEqual(spy.state, .half) + + proxy.update(state: .half) + spy.moveCalls.removeAll() + + // Panel moves internally to .full + spy.move(to: .full, animated: false) + spy.moveCalls.removeAll() + + // Stale re-render (skipped) + proxy.update(state: .half) + + // Deferred binding sync (no move needed — controller already at .full) + proxy.update(state: .full) + spy.moveCalls.removeAll() + + // User requests a new state (e.g. "Move to tip" button) + proxy.update(state: .tip) + + XCTAssertEqual( + spy.moveCalls.count, 1, + "A new intentional state change must trigger move(to:)" + ) + XCTAssertEqual(spy.moveCalls.first?.state, .tip) + XCTAssertEqual(spy.moveCalls.first?.animated, false) + } +}