diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b0ccfec..e90da3b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v2 - name: iOS - ${{ matrix.destination }} - run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild -project "StateViewController.xcodeproj" -scheme "StateViewController-Package" -destination "${{ matrix.destination }}" clean build | xcpretty + run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild -project "StateViewController.xcodeproj" -scheme "StateViewController-Package" -destination "${{ matrix.destination }}" clean test | xcpretty tvOS: name: Test tvOS runs-on: macOS-latest @@ -27,4 +27,4 @@ jobs: steps: - uses: actions/checkout@v2 - name: tvOS - ${{ matrix.destination }} - run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild -project "StateViewController.xcodeproj" -scheme "StateViewController-Package" -destination "${{ matrix.destination }}" clean build | xcpretty \ No newline at end of file + run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild -project "StateViewController.xcodeproj" -scheme "StateViewController-Package" -destination "${{ matrix.destination }}" clean build | xcpretty diff --git a/Package.swift b/Package.swift index 4dc122f..d56880e 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,9 @@ let package = Package( targets: [ .target( name: "StateViewController", - dependencies: []) + dependencies: []), + .testTarget( + name: "StateViewControllerTests", + dependencies: ["StateViewController"]) ] ) diff --git a/Sources/StateViewController/StateViewController.swift b/Sources/StateViewController/StateViewController.swift index 7dd26ce..5b40e31 100644 --- a/Sources/StateViewController/StateViewController.swift +++ b/Sources/StateViewController/StateViewController.swift @@ -2,6 +2,8 @@ import UIKit +// swiftlint:disable file_length + /// A container view controller that manages the appearance of one or more child view controller for any given state. /// /// ## Overview @@ -111,18 +113,40 @@ open class StateViewController: UIViewController { /// `viewDidAppear`, **or** between `viewWillDisappear` and `viewDidDisappear`. fileprivate var isInAppearanceTransition = false + /// Indicates whether the state view controller has appeared and has not yet disappeared. + fileprivate var isVisible = false + + /// The direction of the current appearance transition, or `nil` outside an appearance transition. + fileprivate var appearanceTransitionIsAppearing: Bool? + /// Indicates whether the state view controller is applying an appearance state, as part of its appearance cycle fileprivate var isApplyingAppearanceState = false /// Stores the next needed state to be transitioned to immediately after a current state transition is finished fileprivate var pendingState: (state: State, animated: Bool)? + /// Whether the active state transition is animated. + fileprivate var isStateTransitionAnimated = false + + /// Used to determine whether `didTransition(from:animated:)` requested a newer state. + fileprivate var stateTransitionRequestGeneration: UInt = 0 + + /// Identifies animation completions belonging to the active transition. + fileprivate var activeStateTransitionGeneration: UInt = 0 + /// Set of child view controllers being added as part of a state transition fileprivate var viewControllersBeingAdded: Set = [] /// Set of child view controllers being removed as part of a state transition fileprivate var viewControllersBeingRemoved: Set = [] + /// Appearance transitions begun for child view controllers and awaiting a matching end. + fileprivate var childAppearanceTransitions: [UIViewController: (isAppearing: Bool, animated: Bool)] = [:] + + /// Property animators for the active state transition, retained so lifecycle changes can stop them. + @available(iOS 10, tvOS 10, *) + fileprivate var stateTransitionAnimators: [UIViewController: UIViewPropertyAnimator] = [:] + /// :nodoc: override public final var shouldAutomaticallyForwardAppearanceMethods: Bool { return false // We completely manage forwarding of appearance methods ourselves. @@ -143,10 +167,9 @@ open class StateViewController: UIViewController { override open func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - // When `viewWillAppear(animated:)` is called we do not yet connsider ourselves in an appearance transition - // internally because first we have to assert whether we are changing to an appearannce state. isApplyingAppearanceState = false - isInAppearanceTransition = false + appearanceTransitionIsAppearing = true + isInAppearanceTransition = true // Load the appearance state once let appearanceState = loadAppearanceState() @@ -157,16 +180,11 @@ open class StateViewController: UIViewController { isApplyingAppearanceState = beginStateTransition(to: appearanceState, animated: animated) } - // Prematurely remove view controllers that are being removed. - // As we're not yet setting the `isInAppearanceTransition` to `true`, the appearance methods - // for each child view controller below will be forwarded correctly. - for child in viewControllersBeingRemoved { + // Removed children cannot participate in the appearance transition that is about to begin. + for child in Array(viewControllersBeingRemoved) { removeChild(child, animated: false) } - // Note that we're in an appearance transition - isInAppearanceTransition = true - // Forward begin appearance transitions to child view controllers. forwardBeginApperanceTransition(isAppearing: true, animated: animated) } @@ -177,20 +195,24 @@ open class StateViewController: UIViewController { // Note that we're no longer in an appearance transition isInAppearanceTransition = false + isVisible = true // Forward end appearance transitions to chidl view controllers - forwardEndAppearanceTransition(didAppear: true, animated: animated) + forwardEndAppearanceTransition() // If we're applying the appearance state, finish up by making sure // `didMove(to:)` is called on child view controllers. if isApplyingAppearanceState { - for child in viewControllersBeingAdded { + for child in Array(viewControllersBeingAdded) { didAddChild(child, animated: animated) } } + isApplyingAppearanceState = false + appearanceTransitionIsAppearing = nil + // End state transition if needed. Child view controllers may still be in a transition. - endStateTransitionIfNeeded(animated: animated) + endStateTransitionIfNeeded() } /// :nodoc: @@ -199,18 +221,16 @@ open class StateViewController: UIViewController { isInAppearanceTransition = false - // If we're being dismissed we might as well clear the pending state. - pendingState = nil + // Reverse any outstanding appearance transitions before settling state-transition containment. + appearanceTransitionIsAppearing = false + isInAppearanceTransition = true + invalidateStateTransitionAnimations() - /// If there are view controllers being added as part of a current state transition, we should - // add them immediately. - for child in viewControllersBeingAdded { + // If there are view controllers being added as part of a current state transition, add them immediately. + for child in Array(viewControllersBeingAdded) { didAddChild(child, animated: animated) } - // Note that we're in an appearance transition - isInAppearanceTransition = true - // Forward begin appearance methods forwardBeginApperanceTransition(isAppearing: false, animated: animated) } @@ -221,18 +241,23 @@ open class StateViewController: UIViewController { // Note that we're no longer in an apperance transition isInAppearanceTransition = false + isVisible = false + + invalidateStateTransitionAnimations() - // Prematurely remove all view controllers begin removed - for child in viewControllersBeingRemoved { + // Prematurely remove all view controllers being removed + for child in Array(viewControllersBeingRemoved) { removeChild(child, animated: animated) } // Forward end appearance transitions. Will only affect child view controllers not currently // in a state transition. - forwardEndAppearanceTransition(didAppear: false, animated: animated) + forwardEndAppearanceTransition() + + appearanceTransitionIsAppearing = nil // End state transition if needed. - endStateTransitionIfNeeded(animated: animated) + endStateTransitionIfNeeded() } // MARK: - Container view controller forwarding @@ -272,11 +297,12 @@ open class StateViewController: UIViewController { /// Indicates whether the state of this view controller has been determined. /// In effect, this means that if this value is `true`, you can access `currentState` inside - // `loadAppearanceState()` without resulting in infinite recursion. + /// `loadAppearanceState()` without resulting in infinite recursion. public var hasDeterminedState: Bool { return stateInternal != nil } + // swiftlint:disable unavailable_function /// Loads a state that should represent this view controller immediately as this view controller /// is being presented on screen, and returns it. /// @@ -284,7 +310,6 @@ open class StateViewController: UIViewController { /// method without first asserting that `hasDeterminedState` is `true`. /// /// - Returns: A state - // swiftlint:disable unavailable_function open func loadAppearanceState() -> State { fatalError( "\(String(describing: self)) does not implement loadAppearanceState(), which is required. " + @@ -304,22 +329,25 @@ open class StateViewController: UIViewController { /// - animated: Whether to animate the state transition. public func setNeedsStateTransition(to state: State, animated: Bool) { + stateTransitionRequestGeneration &+= 1 + guard beginStateTransition(to: state, animated: animated) else { return } - guard animated else { - for viewController in viewControllersBeingAdded { + if animated { + performStateTransition(animated: animated) + } else { + for viewController in Array(viewControllersBeingAdded) { didAddChild(viewController, animated: animated) } - for viewController in viewControllersBeingRemoved { + for viewController in Array(viewControllersBeingRemoved) { removeChild(viewController, animated: animated) } - return } - performStateTransition(animated: animated) + endStateTransitionIfNeeded() } // MARK: - Content view controllers @@ -364,7 +392,7 @@ open class StateViewController: UIViewController { } /// Creates the `childContainerView` used as a container view for content view controllers. - // + /// /// - Note: This method is only called once. /// /// - Returns: A `UIView` if not overridden. @@ -480,38 +508,66 @@ fileprivate extension StateViewController { /// - animated: Whether the appearance or disappearance of this view controller is animated func forwardBeginApperanceTransition(isAppearing: Bool, animated: Bool) { - // Don't include view controlellers in a state transition. - // Appearance method forwarding will be performed at a later stage - let excluded = viewControllersBeingAdded.union(viewControllersBeingRemoved) + // Removed view controllers cannot participate in the parent's appearance transition. + let excluded = viewControllersBeingRemoved for viewController in children where excluded.contains(viewController) == false { // Invoke the appropriate callback method - if isAppearing { - childWillAppear(viewController, animated: animated) - } else { - childWillDisappear(viewController, animated: animated) - } - - viewController.beginAppearanceTransition(isAppearing, animated: animated) + beginChildAppearanceTransition( + for: viewController, + isAppearing: isAppearing, + animated: animated + ) } } - func forwardEndAppearanceTransition(didAppear: Bool, animated: Bool) { + func forwardEndAppearanceTransition() { - // Don't include view controlellers in a state transition. - // Appearance method forwarding will be performed at a later stage. - let excluded = viewControllersBeingAdded.union(viewControllersBeingRemoved) + // Removed view controllers cannot participate in the parent's appearance transition. + let excluded = viewControllersBeingRemoved for viewController in children where excluded.contains(viewController) == false { - viewController.endAppearanceTransition() + endChildAppearanceTransition(for: viewController) + } + } - // Invoke the appropriate callback method - if didAppear { - childDidAppear(viewController, animated: animated) - } else { - childDidDisappear(viewController, animated: animated) - } + func beginChildAppearanceTransition( + for child: UIViewController, + isAppearing: Bool, + animated: Bool) { + + guard childAppearanceTransitions[child]?.isAppearing != isAppearing else { + return + } + + childAppearanceTransitions[child] = (isAppearing: isAppearing, animated: animated) + + child.beginAppearanceTransition(isAppearing, animated: animated) + + guard childAppearanceTransitions[child]?.isAppearing == isAppearing else { + return + } + + if isAppearing { + childWillAppear(child, animated: animated) + } else { + childWillDisappear(child, animated: animated) + } + } + + func endChildAppearanceTransition(for child: UIViewController) { + + guard let transition = childAppearanceTransitions.removeValue(forKey: child) else { + return + } + + child.endAppearanceTransition() + + if transition.isAppearing { + childDidAppear(child, animated: transition.animated) + } else { + childDidDisappear(child, animated: transition.animated) } } } @@ -521,12 +577,6 @@ fileprivate extension StateViewController { @discardableResult func beginStateTransition(to state: State, animated: Bool) -> Bool { - // We may not have made any changes to content view controllers, even though we have changed the state. - // Therefore, we must be prepare to end the state transition immediately. - defer { - endStateTransitionIfNeeded(animated: animated) - } - // If we're transitioning between states, we need to abort and wait for the current state // transition to finish. guard isTransitioningBetweenStates == false else { @@ -534,12 +584,15 @@ fileprivate extension StateViewController { return false } + // Note that we're transitioning from the current state. The outer optional marks the transition as active + // even when this is the initial transition and the previous state is `nil`. + transitioningFromState = .some(stateInternal) + isStateTransitionAnimated = animated + activeStateTransitionGeneration &+= 1 + // Invoke callback method, indicating that we will change state willTransition(to: state, animated: animated) - // Note that we're transitioning from a state - transitioningFromState = state - // Update the current state stateInternal = state @@ -584,28 +637,57 @@ fileprivate extension StateViewController { /// Performs the state transition, on a per-view controller basis, and ends the state transition if needed. func performStateTransition(animated: Bool) { + let generation = activeStateTransitionGeneration + // Perform animations for each adding view controller - for viewController in viewControllersBeingAdded { + for viewController in Array(viewControllersBeingAdded) { performStateTransition(for: viewController, isAppearing: true) { + guard self.activeStateTransitionGeneration == generation else { + return + } + self.didAddChild(viewController, animated: animated) - self.endStateTransitionIfNeeded(animated: animated) + self.endStateTransitionIfNeeded() } } // Perform animations for each removing view controller - for viewController in viewControllersBeingRemoved { + for viewController in Array(viewControllersBeingRemoved) { performStateTransition(for: viewController, isAppearing: false) { + guard self.activeStateTransitionGeneration == generation else { + return + } + self.removeChild(viewController, animated: animated) - self.endStateTransitionIfNeeded(animated: animated) + self.endStateTransitionIfNeeded() } } } + func invalidateStateTransitionAnimations() { + guard viewControllersBeingAdded.isEmpty == false || viewControllersBeingRemoved.isEmpty == false else { + return + } + + activeStateTransitionGeneration &+= 1 + + if #available(iOS 10, tvOS 10, *) { + for animator in stateTransitionAnimators.values { + animator.stopAnimation(true) + } + stateTransitionAnimators.removeAll() + } + + for child in viewControllersBeingAdded.union(viewControllersBeingRemoved) + where (child is StateViewControllerTransitioning) == false { + child.view.layer.removeAnimation(forKey: "opacity") + } + } + /// Ends the state transition if a) an apperance transition is not in progress, b) if no /// view controllers are in a state transition. /// - /// - Parameter animated: Whether the state transition was animated. - func endStateTransitionIfNeeded(animated: Bool) { + func endStateTransitionIfNeeded() { // We're not transitioning from a state, so what gives? guard let fromState = transitioningFromState else { @@ -626,12 +708,23 @@ fileprivate extension StateViewController { // Note that we're no longer transitioning from a state transitioningFromState = nil + let animated = isStateTransitionAnimated + isStateTransitionAnimated = false + + let pending = pendingState + pendingState = nil + let requestGeneration = stateTransitionRequestGeneration + // Notify that we're finished transitioning didTransition(from: fromState, animated: animated) + // A request made by `didTransition` is newer than anything that was previously pending. + guard requestGeneration == stateTransitionRequestGeneration else { + return + } + // If we still need another state, let's transition to it immediately. - if let (state, animated) = pendingState { - pendingState = nil + if let (state, animated) = pending { setNeedsStateTransition(to: state, animated: animated) } } @@ -650,6 +743,8 @@ fileprivate extension StateViewController { isAppearing: Bool, completion: @escaping () -> Void) { + let generation = activeStateTransitionGeneration + if let transitioningProtocol = viewController as? StateViewControllerTransitioning { transitioningProtocol.stateTransitionWillBegin(isAppearing: isAppearing) } else { @@ -671,23 +766,33 @@ fileprivate extension StateViewController { let duration = transitioningProtocol?.stateTransitionDuration(isAppearing: isAppearing) ?? 0.35 let delay = transitioningProtocol?.stateTransitionDelay(isAppearing: isAppearing) ?? 0 - // For iOS 10 and above, we use UIViewPropertyAnimator - if #available(iOS 10, tvOS 10, *) { - let animator = UIViewPropertyAnimator(duration: duration, dampingRatio: 1, animations: animations) + let finish = { + guard self.activeStateTransitionGeneration == generation else { + return + } - animator.addCompletion { position in + transitioningProtocol?.stateTransitionDidEnd(isAppearing: isAppearing) - guard position == .end else { - return - } + if transitioningProtocol == nil { + viewController.view.alpha = isAppearing ? 1 : 0 + } + + completion() + } - transitioningProtocol?.stateTransitionDidEnd(isAppearing: isAppearing) + // For iOS 10 and above, we use UIViewPropertyAnimator + if #available(iOS 10, tvOS 10, *) { + let animator = UIViewPropertyAnimator(duration: duration, dampingRatio: 1, animations: animations) + stateTransitionAnimators[viewController] = animator - if transitioningProtocol == nil { - viewController.view.alpha = isAppearing ? 1 : 0 + animator.addCompletion { [weak self, weak animator] _ in + if let self = self, + let animator = animator, + self.stateTransitionAnimators[viewController] === animator { + self.stateTransitionAnimators.removeValue(forKey: viewController) } - completion() + finish() } animator.startAnimation(afterDelay: delay) @@ -700,10 +805,8 @@ fileprivate extension StateViewController { initialSpringVelocity: 0, options: [], animations: animations - ) { finished in - if finished { - completion() - } + ) { _ in + finish() } } } @@ -719,7 +822,10 @@ fileprivate extension StateViewController { viewController.view.translatesAutoresizingMaskIntoConstraints = true viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] viewController.view.bounds.size = childContainerView.bounds.size - viewController.view.center = childContainerView.center + viewController.view.center = CGPoint( + x: childContainerView.bounds.midX, + y: childContainerView.bounds.midY + ) childContainerView.insertSubview(viewController.view, at: index) viewController.view.layoutIfNeeded() @@ -770,11 +876,10 @@ fileprivate extension StateViewController { addChild(child) - // If we're not in an appearance transition, forward appearance methods. - // If we are, appearance methods will be forwarded at a later time - if isInAppearanceTransition == false { - childWillAppear(child, animated: animated) - child.beginAppearanceTransition(true, animated: animated) + // Children added while appearing or visible need their own appearance transition. Children added while + // disappearing or hidden should remain hidden without receiving appearance callbacks. + if appearanceTransitionIsAppearing ?? isVisible { + beginChildAppearanceTransition(for: child, isAppearing: true, animated: animated) } viewControllersBeingAdded.insert(child) @@ -786,11 +891,9 @@ fileprivate extension StateViewController { return } - // If we're not in an appearance transition, forward appearance methods. - // If we are, appearance methods will be forwarded at a later time + // During a parent appearance transition, the matching end is forwarded by `viewDidAppear`. if isInAppearanceTransition == false { - child.endAppearanceTransition() - childDidAppear(child, animated: animated) + endChildAppearanceTransition(for: child) } child.didMove(toParent: self) @@ -805,11 +908,9 @@ fileprivate extension StateViewController { child.willMove(toParent: nil) - // If we're not in an appearance transition, forward appearance methods. - // If we are, appearance methods will be forwarded at a later time - if isInAppearanceTransition == false { - childWillDisappear(child, animated: animated) - child.beginAppearanceTransition(false, animated: animated) + // A visible child or one already participating in the parent's appearance cycle must disappear cleanly. + if isVisible || childAppearanceTransitions[child] != nil { + beginChildAppearanceTransition(for: child, isAppearing: false, animated: animated) } viewControllersBeingRemoved.insert(child) @@ -823,12 +924,8 @@ fileprivate extension StateViewController { child.view.removeFromSuperview() - // If we're not in an appearance transition, forward appearance methods. - // If we are, appearance methods will be forwarded at a later time - if isInAppearanceTransition == false { - child.endAppearanceTransition() - childDidDisappear(child, animated: animated) - } + // Removed children cannot be reached by the parent's later appearance forwarding. + endChildAppearanceTransition(for: child) child.removeFromParent() viewControllersBeingRemoved.remove(child) diff --git a/StateViewController.xcodeproj/project.pbxproj b/StateViewController.xcodeproj/project.pbxproj index 0bbf41c..2d22627 100644 --- a/StateViewController.xcodeproj/project.pbxproj +++ b/StateViewController.xcodeproj/project.pbxproj @@ -20,8 +20,9 @@ productRefGroup = "OBJ_12"; projectDirPath = "."; targets = ( - "StateViewController::StateViewController", - "StateViewController::SwiftPMPackageDescription" + "StateViewController::StateViewController", + "StateViewController::StateViewControllerTests", + "StateViewController::SwiftPMPackageDescription" ); }; "OBJ_10" = { @@ -32,6 +33,8 @@ "OBJ_11" = { isa = "PBXGroup"; children = ( + "OBJ_36", + "OBJ_47" ); name = "Tests"; path = ""; @@ -40,7 +43,8 @@ "OBJ_12" = { isa = "PBXGroup"; children = ( - "StateViewController::StateViewController::Product" + "StateViewController::StateViewController::Product", + "OBJ_37" ); name = "Products"; path = ""; @@ -307,6 +311,105 @@ isa = "PBXBuildFile"; fileRef = "OBJ_6"; }; + "OBJ_36" = { + isa = "PBXFileReference"; + path = "Tests/StateViewControllerTests/StateViewControllerTests.swift"; + sourceTree = "SOURCE_ROOT"; + }; + "OBJ_47" = { + isa = "PBXFileReference"; + path = "Tests/StateViewControllerTests/Info.plist"; + sourceTree = "SOURCE_ROOT"; + }; + "OBJ_37" = { + isa = "PBXFileReference"; + explicitFileType = "wrapper.cfbundle"; + path = "StateViewControllerTests.xctest"; + sourceTree = "BUILT_PRODUCTS_DIR"; + }; + "OBJ_38" = { + isa = "PBXBuildFile"; + fileRef = "OBJ_36"; + }; + "OBJ_39" = { + isa = "PBXSourcesBuildPhase"; + files = ( + "OBJ_38" + ); + }; + "OBJ_40" = { + isa = "PBXBuildFile"; + fileRef = "StateViewController::StateViewController::Product"; + }; + "OBJ_41" = { + isa = "PBXFrameworksBuildPhase"; + files = ( + "OBJ_40" + ); + }; + "OBJ_42" = { + isa = "PBXContainerItemProxy"; + containerPortal = "OBJ_1"; + proxyType = "1"; + remoteGlobalIDString = "StateViewController::StateViewController"; + remoteInfo = "StateViewController"; + }; + "OBJ_43" = { + isa = "PBXTargetDependency"; + target = "StateViewController::StateViewController"; + targetProxy = "OBJ_42"; + }; + "OBJ_44" = { + isa = "XCBuildConfiguration"; + buildSettings = { + ENABLE_TESTABILITY = "YES"; + INFOPLIST_FILE = "Tests/StateViewControllerTests/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = "8.0"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@loader_path/Frameworks", + "@executable_path/Frameworks" + ); + PRODUCT_BUNDLE_IDENTIFIER = "StateViewControllerTests"; + PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = "iphoneos"; + SKIP_INSTALL = "YES"; + SWIFT_VERSION = "5.0"; + TARGET_NAME = "StateViewControllerTests"; + }; + name = "Debug"; + }; + "OBJ_45" = { + isa = "XCBuildConfiguration"; + buildSettings = { + ENABLE_TESTABILITY = "YES"; + INFOPLIST_FILE = "Tests/StateViewControllerTests/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = "8.0"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@loader_path/Frameworks", + "@executable_path/Frameworks" + ); + PRODUCT_BUNDLE_IDENTIFIER = "StateViewControllerTests"; + PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = "iphoneos"; + SKIP_INSTALL = "YES"; + SWIFT_VERSION = "5.0"; + TARGET_NAME = "StateViewControllerTests"; + }; + name = "Release"; + }; + "OBJ_46" = { + isa = "XCConfigurationList"; + buildConfigurations = ( + "OBJ_44", + "OBJ_45" + ); + defaultConfigurationIsVisible = "0"; + defaultConfigurationName = "Release"; + }; "OBJ_4" = { isa = "XCBuildConfiguration"; buildSettings = { @@ -408,6 +511,21 @@ productReference = "StateViewController::StateViewController::Product"; productType = "com.apple.product-type.framework"; }; + "StateViewController::StateViewControllerTests" = { + isa = "PBXNativeTarget"; + buildConfigurationList = "OBJ_46"; + buildPhases = ( + "OBJ_39", + "OBJ_41" + ); + dependencies = ( + "OBJ_43" + ); + name = "StateViewControllerTests"; + productName = "StateViewControllerTests"; + productReference = "OBJ_37"; + productType = "com.apple.product-type.bundle.unit-test"; + }; "StateViewController::StateViewController::Product" = { isa = "PBXFileReference"; path = "StateViewController.framework"; diff --git a/StateViewController.xcodeproj/xcshareddata/xcschemes/StateViewController-Package.xcscheme b/StateViewController.xcodeproj/xcshareddata/xcschemes/StateViewController-Package.xcscheme index 6c5e51c..53e422e 100644 --- a/StateViewController.xcodeproj/xcshareddata/xcschemes/StateViewController-Package.xcscheme +++ b/StateViewController.xcodeproj/xcshareddata/xcschemes/StateViewController-Package.xcscheme @@ -20,6 +20,20 @@ ReferencedContainer = "container:StateViewController.xcodeproj"> + + + + + + + + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Tests/StateViewControllerTests/StateViewControllerTests.swift b/Tests/StateViewControllerTests/StateViewControllerTests.swift new file mode 100644 index 0000000..c054804 --- /dev/null +++ b/Tests/StateViewControllerTests/StateViewControllerTests.swift @@ -0,0 +1,328 @@ +#if canImport(UIKit) + +@testable import StateViewController +import XCTest + +final class StateViewControllerTests: XCTestCase { + + func testNonanimatedTransitionFinishesAndReportsPreviousState() { + let controller = TestStateViewController() + _ = controller.view + + controller.setNeedsStateTransition(to: .first, animated: false) + + XCTAssertFalse(controller.isTransitioningBetweenStates) + XCTAssertEqual(controller.transitions.count, 1) + XCTAssertNil(controller.transitions[0].from) + XCTAssertEqual(controller.transitions[0].to, .first) + + controller.setNeedsStateTransition(to: .second, animated: false) + + XCTAssertFalse(controller.isTransitioningBetweenStates) + XCTAssertEqual(controller.transitions.count, 2) + XCTAssertEqual(controller.transitions[1].from, .first) + XCTAssertEqual(controller.transitions[1].to, .second) + XCTAssertIdentical(controller.children.first, controller.secondChild) + } + + func testOffscreenTransitionsDoNotForwardAppearanceCallbacks() { + let controller = TestStateViewController() + _ = controller.view + + controller.setNeedsStateTransition(to: .first, animated: false) + controller.setNeedsStateTransition(to: .second, animated: false) + + XCTAssertEqual(controller.firstChild.appearanceEvents, []) + XCTAssertEqual(controller.secondChild.appearanceEvents, []) + XCTAssertEqual(controller.childAppearanceEvents, []) + } + + func testTransitionAfterDisappearanceDoesNotForwardAppearanceCallbacks() { + let controller = TestStateViewController() + + controller.beginAppearanceTransition(true, animated: false) + controller.endAppearanceTransition() + controller.beginAppearanceTransition(false, animated: false) + controller.endAppearanceTransition() + + controller.firstChild.appearanceEvents = [] + controller.childAppearanceEvents = [] + controller.setNeedsStateTransition(to: .second, animated: false) + + XCTAssertEqual(controller.firstChild.appearanceEvents, []) + XCTAssertEqual(controller.secondChild.appearanceEvents, []) + XCTAssertEqual(controller.childAppearanceEvents, []) + } + + func testInitialNonanimatedAppearanceIsForwardedOnce() { + let parent = UIViewController() + let controller = TestStateViewController() + _ = parent.view + parent.addChild(controller) + parent.view.addSubview(controller.view) + + controller.beginAppearanceTransition(true, animated: false) + controller.endAppearanceTransition() + controller.didMove(toParent: parent) + + XCTAssertTrue(controller.wasMovingToParentInViewWillAppear) + XCTAssertEqual(controller.firstChild.appearanceEvents, ["willAppear", "didAppear"]) + XCTAssertEqual( + controller.childAppearanceEvents, + ["first.willAppear", "first.didAppear"] + ) + XCTAssertFalse(controller.isTransitioningBetweenStates) + } + + func testInterruptedInitialAppearanceDoesNotReportDidAppear() { + let controller = TestStateViewController() + + controller.beginAppearanceTransition(true, animated: false) + controller.beginAppearanceTransition(false, animated: false) + controller.endAppearanceTransition() + + XCTAssertEqual( + controller.firstChild.appearanceEvents, + ["willAppear", "willDisappear", "didDisappear"] + ) + XCTAssertEqual( + controller.childAppearanceEvents, + ["first.willAppear", "first.willDisappear", "first.didDisappear"] + ) + XCTAssertFalse(controller.isTransitioningBetweenStates) + } + + func testChildAppearanceCallbackCanSynchronouslyChangeState() { + let controller = TestStateViewController() + controller.beginAppearanceTransition(true, animated: false) + controller.endAppearanceTransition() + controller.onChildWillDisappear = { [weak controller] in + controller?.setNeedsStateTransition(to: .second, animated: false) + } + + controller.beginAppearanceTransition(false, animated: false) + controller.endAppearanceTransition() + + XCTAssertEqual( + controller.firstChild.appearanceEvents, + ["willAppear", "didAppear", "willDisappear", "didDisappear"] + ) + XCTAssertEqual(controller.currentState, .second) + XCTAssertFalse(controller.isTransitioningBetweenStates) + } + + func testDidTransitionRequestSupersedesOlderPendingState() { + let controller = TestStateViewController() + controller.usesSharedChild = true + controller.onDidTransition = { [weak controller] state in + if state == .first { + controller?.setNeedsStateTransition(to: .newest, animated: false) + } + } + + controller.beginAppearanceTransition(true, animated: false) + controller.setNeedsStateTransition(to: .pending, animated: false) + controller.endAppearanceTransition() + + XCTAssertEqual(controller.currentState, .newest) + XCTAssertEqual(controller.transitions.map { $0.to }, [.first, .newest]) + XCTAssertFalse(controller.isTransitioningBetweenStates) + } + + func testChildUsesContainerBoundsCoordinateSpace() { + let controller = TestStateViewController() + _ = controller.view + controller.childContainerView.bounds = CGRect(x: 10, y: 20, width: 100, height: 200) + controller.childContainerView.center = CGPoint(x: 250, y: 300) + + controller.setNeedsStateTransition(to: .first, animated: false) + + XCTAssertEqual(controller.firstChild.view.center.x, 60, accuracy: 0.001) + XCTAssertEqual(controller.firstChild.view.center.y, 120, accuracy: 0.001) + XCTAssertEqual(controller.firstChild.view.bounds.size.width, 100, accuracy: 0.001) + XCTAssertEqual(controller.firstChild.view.bounds.size.height, 200, accuracy: 0.001) + } + + func testPendingAnimatedAdditionJoinsParentAppearance() { + let controller = TestStateViewController() + controller.firstChild.transitionDuration = 0.05 + + controller.setNeedsStateTransition(to: .first, animated: true) + controller.beginAppearanceTransition(true, animated: false) + controller.endAppearanceTransition() + + XCTAssertEqual(controller.firstChild.appearanceEvents, ["willAppear", "didAppear"]) + XCTAssertEqual( + controller.childAppearanceEvents, + ["first.willAppear", "first.didAppear"] + ) + + let completed = expectation(description: "Animation completed") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + completed.fulfill() + } + wait(for: [completed], timeout: 1) + XCTAssertFalse(controller.isTransitioningBetweenStates) + } + + func testStaleAnimationCannotCompleteNewerTransitionForReusedChild() { + let controller = TestStateViewController() + controller.firstChild.transitionDuration = 0.1 + controller.secondChild.transitionDuration = 0.1 + + controller.beginAppearanceTransition(true, animated: false) + controller.endAppearanceTransition() + controller.setNeedsStateTransition(to: .second, animated: true) + controller.beginAppearanceTransition(false, animated: false) + controller.endAppearanceTransition() + controller.setNeedsStateTransition(to: .first, animated: true) + + let completed = expectation(description: "Animations completed") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + completed.fulfill() + } + wait(for: [completed], timeout: 1) + + XCTAssertEqual(controller.currentState, .first) + XCTAssertIdentical(controller.children.first, controller.firstChild) + XCTAssertEqual(controller.transitions.map { $0.to }, [.first, .second, .first]) + XCTAssertFalse(controller.isTransitioningBetweenStates) + } +} + +private enum TestState: Equatable { + case first + case second + case pending + case newest +} + +private final class TestStateViewController: StateViewController { + + struct Transition { + let from: TestState? + let to: TestState + let animated: Bool + } + + let firstChild = RecordingViewController(identifier: "first") + let secondChild = RecordingViewController(identifier: "second") + let pendingChild = RecordingViewController(identifier: "pending") + let newestChild = RecordingViewController(identifier: "newest") + + var appearanceState = TestState.first + var usesSharedChild = false + var wasMovingToParentInViewWillAppear = false + var transitions: [Transition] = [] + var childAppearanceEvents: [String] = [] + var onDidTransition: ((TestState) -> Void)? + var onChildWillDisappear: (() -> Void)? + + override func loadAppearanceState() -> TestState { + appearanceState + } + + override func viewWillAppear(_ animated: Bool) { + wasMovingToParentInViewWillAppear = isMovingToParent + super.viewWillAppear(animated) + } + + override func children(for state: TestState) -> [UIViewController] { + if usesSharedChild { + return [firstChild] + } + + switch state { + case .first: + return [firstChild] + case .second: + return [secondChild] + case .pending: + return [pendingChild] + case .newest: + return [newestChild] + } + } + + override func didTransition(from previousState: TestState?, animated: Bool) { + transitions.append(Transition(from: previousState, to: currentState, animated: animated)) + onDidTransition?(currentState) + } + + override func childWillAppear(_ child: UIViewController, animated: Bool) { + childAppearanceEvents.append("\(identifier(for: child)).willAppear") + } + + override func childDidAppear(_ child: UIViewController, animated: Bool) { + childAppearanceEvents.append("\(identifier(for: child)).didAppear") + } + + override func childWillDisappear(_ child: UIViewController, animated: Bool) { + childAppearanceEvents.append("\(identifier(for: child)).willDisappear") + onChildWillDisappear?() + } + + override func childDidDisappear(_ child: UIViewController, animated: Bool) { + childAppearanceEvents.append("\(identifier(for: child)).didDisappear") + } + + private func identifier(for child: UIViewController) -> String { + (child as? RecordingViewController)?.identifier ?? "unknown" + } +} + +private final class RecordingViewController: UIViewController { + + let identifier: String + var appearanceEvents: [String] = [] + var transitionDuration: TimeInterval = 0 + + init(identifier: String) { + self.identifier = identifier + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + appearanceEvents.append("willAppear") + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + appearanceEvents.append("didAppear") + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + appearanceEvents.append("willDisappear") + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + appearanceEvents.append("didDisappear") + } +} + +extension RecordingViewController: StateViewControllerTransitioning { + + func stateTransitionDuration(isAppearing: Bool) -> TimeInterval { + transitionDuration + } + + func stateTransitionWillBegin(isAppearing: Bool) {} + + func stateTransitionDidEnd(isAppearing: Bool) {} + + func animateAlongsideStateTransition(isAppearing: Bool) {} + + func stateTransitionDelay(isAppearing: Bool) -> TimeInterval { + 0 + } +} + +#endif