diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 4c06cd3da2d..168177cff9d 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -636,6 +636,7 @@ 4CD675C522E7CF22008010D2 /* ConversationViewController+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD675C422E7CF22008010D2 /* ConversationViewController+OWS.swift */; }; 4CD675C722E7D393008010D2 /* MediaPresentationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD675C622E7D393008010D2 /* MediaPresentationContext.swift */; }; 4CFF115323A9C2130007F9D7 /* UnreadIndicatorInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF115223A9C2130007F9D7 /* UnreadIndicatorInteraction.swift */; }; + 4FC9CD52D7244527B6451D49 /* UIKit+AnimationsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A65D45A563D649F88E03BAAD /* UIKit+AnimationsTest.swift */; }; 50007B8A2BFE7676005652A2 /* StoryRecipient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50007B892BFE7676005652A2 /* StoryRecipient.swift */; }; 50007B8C2BFE79AD005652A2 /* StoryRecipientStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50007B8B2BFE79AD005652A2 /* StoryRecipientStore.swift */; }; 5000CA312B1F97EE00BB8EFF /* JobQueueRunnerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5000CA302B1F97EE00BB8EFF /* JobQueueRunnerTest.swift */; }; @@ -6147,6 +6148,7 @@ A33E43CA8A572CA70089C4CC /* Pods-SignalServiceKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalServiceKit.debug.xcconfig"; path = "Target Support Files/Pods-SignalServiceKit/Pods-SignalServiceKit.debug.xcconfig"; sourceTree = ""; }; A566C0C0B69138202C0367E6 /* Pods-Signal.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Signal.app store release.xcconfig"; path = "Target Support Files/Pods-Signal/Pods-Signal.app store release.xcconfig"; sourceTree = ""; }; A5E7C674248C5442007C949A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = translations/en.lproj/InfoPlist.strings; sourceTree = ""; }; + A65D45A563D649F88E03BAAD /* UIKit+AnimationsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIKit+AnimationsTest.swift"; sourceTree = ""; }; A780A90C5DDC028939E97635 /* LowDiskSpaceManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LowDiskSpaceManager.swift; sourceTree = ""; }; AB0C401B2E5BADCE00A1B2C3 /* NotificationSettingsBadgeCountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsBadgeCountViewController.swift; sourceTree = ""; }; B3F39202F831935AAE1C5F54 /* Pods_SignalUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SignalUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -9321,6 +9323,7 @@ 7609819329382EFF008F8300 /* UIGeometry+Signal.swift */, 768FC7B52F32A0AB00707F72 /* UIImage+Blur.swift */, 76F4B580293ACCD200A7CF2F /* UIKit+Animations.swift */, + A65D45A563D649F88E03BAAD /* UIKit+AnimationsTest.swift */, 762A416C2A383ABF00057955 /* UIKit+Image.swift */, 762A416A2A38397500057955 /* UIKit+Text.swift */, 766CE0D92A32E52300AD609D /* UIStackView+SignalUI.swift */, @@ -18408,6 +18411,7 @@ D9C5634F2EB438500001626A /* OWSByteCountFormatStyleTest.swift in Sources */, 50BF510E2BB2033800C2C309 /* RecipientPickerViewControllerTest.swift in Sources */, B0AD302792B460C279F2579E /* RecipientPickerViewModelTests.swift in Sources */, + 4FC9CD52D7244527B6451D49 /* UIKit+AnimationsTest.swift in Sources */, 50BF51102BB2035400C2C309 /* UIStackView+SignalUITest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/SignalUI/UIKitExtensions/UIKit+Animations.swift b/SignalUI/UIKitExtensions/UIKit+Animations.swift index d04bdd8e7c6..e6a3fa1c77f 100644 --- a/SignalUI/UIKitExtensions/UIKit+Animations.swift +++ b/SignalUI/UIKitExtensions/UIKit+Animations.swift @@ -103,67 +103,149 @@ public extension UIView { ) } + /// Shows or hides the view by fading it, using a 0.2 s animation if `animated` is true. + /// + /// See ``setIsHidden(_:withAnimationDuration:completion:)``. func setIsHidden(_ isHidden: Bool, animated: Bool, completion: ((Bool) -> Void)? = nil) { setIsHidden(isHidden, withAnimationDuration: animated ? 0.2 : 0, completion: completion) } + /// Shows or hides the view by fading its `alpha`. + /// + /// A show sets `isHidden` to false right away and fades `alpha` in to 1. A hide fades `alpha` out, + /// and only once the animation finishes sets `isHidden` to true and restores `alpha` to 1. + /// + /// Changes may overlap: the most recent one wins, including over pending changes made with + /// ``setIsHidden(_:using:)``. A change with a `duration` of 0 is applied immediately. + /// + /// - Parameter completion: Called when the change has been applied. Called synchronously if + /// the view needed no animation, for example because it was already in the requested state. func setIsHidden(_ isHidden: Bool, withAnimationDuration duration: TimeInterval, completion: ((Bool) -> Void)? = nil) { - guard duration > 0, isHidden != self.isHidden else { - self.isHidden = isHidden + guard duration > 0, let change = beginAnimatedIsHiddenChange(isHidden) else { + setIsHiddenWithoutAnimation(isHidden) completion?(true) return } - if self.isHidden, alpha > 0 { - UIView.performWithoutAnimation { - self.alpha = 0 - self.isHidden = false - } - } - UIView.animate( withDuration: duration, animations: { - self.alpha = isHidden ? 0 : 1 + self.animateIsHiddenChange(change) }, completion: { finished in - guard finished else { - completion?(false) - return - } - if isHidden { - self.alpha = 1 - self.isHidden = true - } - completion?(true) + self.completeIsHiddenChange(change, finished: finished) + completion?(finished) }, ) } + /// Shows or hides the view by fading its `alpha` using `animator`. + /// + /// A show sets `isHidden` to false right away and fades `alpha` in to 1. A hide fades `alpha` out, + /// and only once `animator` finishes at its end sets `isHidden` to true and restores `alpha` to 1. + /// + /// Changes may overlap: the most recent one wins, even if the animator of an earlier change + /// hasn't started yet, or is stopped or reversed. + /// + /// - Parameter animator: The animator to add the fade to, which the caller must start. + /// Pass nil to apply the change immediately. func setIsHidden(_ isHidden: Bool, using animator: UIViewPropertyAnimator?) { - guard self.isHidden != isHidden else { + guard let animator, let change = beginAnimatedIsHiddenChange(isHidden) else { + setIsHiddenWithoutAnimation(isHidden) return } - guard let animator else { - self.isHidden = isHidden - return + + animator.addAnimations { + self.animateIsHiddenChange(change) } + animator.addCompletion { position in + self.completeIsHiddenChange(change, finished: position == .end) + } + } +} + +// MARK: - Animated isHidden changes + +/// An animated change of `UIView.isHidden`, used to tell whether it has been superseded. +private final class IsHiddenChange { + let isHidden: Bool + + init(isHidden: Bool) { + self.isHidden = isHidden + } +} - if self.isHidden, alpha > 0 { +private extension UIView { + + static var pendingIsHiddenChangeKey: UInt8 = 0 + + /// The most recent animated change of `isHidden`, until it completes. + /// + /// `isHidden` is only updated once an animated hide completes, so it can't be used + /// to tell which state the view is heading to while an animation is in flight. + var pendingIsHiddenChange: IsHiddenChange? { + get { + objc_getAssociatedObject(self, &UIView.pendingIsHiddenChangeKey) as? IsHiddenChange + } + set { + objc_setAssociatedObject(self, &UIView.pendingIsHiddenChangeKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + /// Makes `isHidden` the view's pending change and prepares the view to animate to it. + /// + /// - Returns: The new pending change, or nil if the view is already in that state and no + /// other change is pending, in which case there is nothing to animate. + func beginAnimatedIsHiddenChange(_ isHidden: Bool) -> IsHiddenChange? { + // A pending change is superseded even if it's heading to the same state: + // its animator might never finish at its end. + guard pendingIsHiddenChange != nil || self.isHidden != isHidden else { + return nil + } + + let change = IsHiddenChange(isHidden: isHidden) + pendingIsHiddenChange = change + + if !isHidden, self.isHidden { UIView.performWithoutAnimation { self.alpha = 0 self.isHidden = false } } - animator.addAnimations { - self.alpha = isHidden ? 0 : 1 + return change + } + + /// Called from the animation block of `change`. + func animateIsHiddenChange(_ change: IsHiddenChange) { + // Animators run their animation blocks when they start, + // which might be after a later change has been made. + guard pendingIsHiddenChange === change else { + return } - animator.addCompletion { position in - guard isHidden, position == .end else { return } + alpha = change.isHidden ? 0 : 1 + } + + /// Called from the completion handler of `change`. + func completeIsHiddenChange(_ change: IsHiddenChange, finished: Bool) { + guard pendingIsHiddenChange === change else { + return + } + pendingIsHiddenChange = nil + + guard finished, change.isHidden else { + return + } + alpha = 1 + isHidden = true + } - self.alpha = 1 - self.isHidden = true + /// Applies `isHidden` immediately, superseding any pending change. + func setIsHiddenWithoutAnimation(_ isHidden: Bool) { + if pendingIsHiddenChange != nil { + pendingIsHiddenChange = nil + alpha = 1 } + self.isHidden = isHidden } } diff --git a/SignalUI/UIKitExtensions/UIKit+AnimationsTest.swift b/SignalUI/UIKitExtensions/UIKit+AnimationsTest.swift new file mode 100644 index 00000000000..809aa41156c --- /dev/null +++ b/SignalUI/UIKitExtensions/UIKit+AnimationsTest.swift @@ -0,0 +1,257 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SignalUI +import UIKit +import XCTest + +final class UIKitAnimationsTest: XCTestCase { + + private static let timeout: TimeInterval = 5 + + private var window: UIWindow! + + override func setUp() { + super.setUp() + window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + window.isHidden = false + } + + override func tearDown() { + window.isHidden = true + window = nil + super.tearDown() + } + + // MARK: - setIsHidden(_:using:) + + func testHideAndShowWithAnimator() { + let view = makeView() + + let hideCompleted = startAnimator { view.setIsHidden(true, using: $0) } + wait(for: [hideCompleted], timeout: Self.timeout) + XCTAssertTrue(view.isHidden) + XCTAssertEqual(view.alpha, 1) + + let showCompleted = startAnimator { view.setIsHidden(false, using: $0) } + wait(for: [showCompleted], timeout: Self.timeout) + XCTAssertFalse(view.isHidden) + XCTAssertEqual(view.alpha, 1) + } + + func testShowDuringHideAnimation() { + let view = makeView() + + let hideCompleted = startAnimator { view.setIsHidden(true, using: $0) } + let showCompleted = startAnimator { view.setIsHidden(false, using: $0) } + wait(for: [hideCompleted, showCompleted], timeout: Self.timeout) + + XCTAssertFalse(view.isHidden) + XCTAssertEqual(view.alpha, 1) + } + + func testShowWithoutAnimatorDuringHideAnimation() { + let view = makeView() + + let hideCompleted = startAnimator { view.setIsHidden(true, using: $0) } + view.setIsHidden(false, using: nil) + XCTAssertFalse(view.isHidden) + XCTAssertEqual(view.alpha, 1) + + wait(for: [hideCompleted], timeout: Self.timeout) + XCTAssertFalse(view.isHidden) + XCTAssertEqual(view.alpha, 1) + } + + func testHideDuringShowAnimation() { + let view = makeView() + view.isHidden = true + + let showCompleted = startAnimator { view.setIsHidden(false, using: $0) } + let hideCompleted = startAnimator { view.setIsHidden(true, using: $0) } + wait(for: [showCompleted, hideCompleted], timeout: Self.timeout) + + XCTAssertTrue(view.isHidden) + XCTAssertEqual(view.alpha, 1) + } + + func testHideDuringHideAnimation() { + let view = makeView() + + // The second animator has nothing else to animate: it must still complete. + let firstHideCompleted = startAnimator { view.setIsHidden(true, using: $0) } + let secondHideCompleted = startAnimator { view.setIsHidden(true, using: $0) } + wait(for: [firstHideCompleted, secondHideCompleted], timeout: Self.timeout) + XCTAssertTrue(view.isHidden) + XCTAssertEqual(view.alpha, 1) + + let showCompleted = startAnimator { view.setIsHidden(false, using: $0) } + wait(for: [showCompleted], timeout: Self.timeout) + XCTAssertFalse(view.isHidden) + XCTAssertEqual(view.alpha, 1) + } + + func testHideDuringHideAnimationThatIsReversed() { + let view = makeView() + let otherView = makeView() + + let firstHideAnimator = makeAnimator() + view.setIsHidden(true, using: firstHideAnimator) + let firstHideCompleted = start(firstHideAnimator) + let secondHideCompleted = startAnimator { animator in + view.setIsHidden(true, using: animator) + otherView.setIsHidden(true, using: animator) + } + firstHideAnimator.isReversed = true + wait(for: [firstHideCompleted, secondHideCompleted], timeout: Self.timeout) + + XCTAssertTrue(view.isHidden) + XCTAssertEqual(view.alpha, 1) + } + + func testHideWithoutAnimatorDuringHideAnimation() { + let view = makeView() + + let hideCompleted = startAnimator { view.setIsHidden(true, using: $0) } + view.setIsHidden(true, using: nil) + XCTAssertTrue(view.isHidden) + XCTAssertEqual(view.alpha, 1) + + wait(for: [hideCompleted], timeout: Self.timeout) + XCTAssertTrue(view.isHidden) + XCTAssertEqual(view.alpha, 1) + } + + func testShowBeforeHideAnimationStarts() { + let view = makeView() + + let hideAnimator = makeAnimator() + view.setIsHidden(true, using: hideAnimator) + view.setIsHidden(false, using: nil) + wait(for: [start(hideAnimator)], timeout: Self.timeout) + + XCTAssertFalse(view.isHidden) + XCTAssertEqual(view.alpha, 1) + } + + func testHideAfterHideAnimatorThatNeverRuns() { + let view = makeView() + + // An animator that is never started never calls its completion handlers. + view.setIsHidden(true, using: makeAnimator()) + + let hideCompleted = startAnimator { view.setIsHidden(true, using: $0) } + wait(for: [hideCompleted], timeout: Self.timeout) + XCTAssertTrue(view.isHidden) + XCTAssertEqual(view.alpha, 1) + } + + func testShowHiddenTransparentView() { + let view = makeView() + view.isHidden = true + view.alpha = 0 + + let showCompleted = startAnimator { view.setIsHidden(false, using: $0) } + wait(for: [showCompleted], timeout: Self.timeout) + + XCTAssertFalse(view.isHidden) + XCTAssertEqual(view.alpha, 1) + } + + func testAlphaChangedWithShowDuringHideAnimation() { + let view = makeView() + + // Eg a toolbar that is also faded out while the caption is being edited. + let hideCompleted = startAnimator { view.setIsHidden(true, using: $0) } + let showCompleted = startAnimator { animator in + view.setIsHidden(false, using: animator) + animator.addAnimations { view.alpha = 0 } + } + wait(for: [hideCompleted, showCompleted], timeout: Self.timeout) + + XCTAssertFalse(view.isHidden) + XCTAssertEqual(view.alpha, 0) + } + + // MARK: - setIsHidden(_:animated:completion:) + + func testAnimatedHideAndShow() { + let view = makeView() + + let hideCompleted = expectation(description: "Hide completed") + view.setIsHidden(true, animated: true) { finished in + XCTAssertTrue(finished) + hideCompleted.fulfill() + } + wait(for: [hideCompleted], timeout: Self.timeout) + XCTAssertTrue(view.isHidden) + XCTAssertEqual(view.alpha, 1) + + var didCompleteRedundantHide = false + view.setIsHidden(true, animated: true) { _ in didCompleteRedundantHide = true } + XCTAssertTrue(didCompleteRedundantHide) + + let showCompleted = expectation(description: "Show completed") + view.setIsHidden(false, animated: true) { finished in + XCTAssertTrue(finished) + showCompleted.fulfill() + } + wait(for: [showCompleted], timeout: Self.timeout) + XCTAssertFalse(view.isHidden) + XCTAssertEqual(view.alpha, 1) + } + + func testAnimatedShowDuringAnimatedHide() { + let view = makeView() + + let hideCompleted = expectation(description: "Hide completed") + view.setIsHidden(true, animated: true) { _ in hideCompleted.fulfill() } + let showCompleted = expectation(description: "Show completed") + view.setIsHidden(false, animated: true) { _ in showCompleted.fulfill() } + wait(for: [hideCompleted, showCompleted], timeout: Self.timeout) + + XCTAssertFalse(view.isHidden) + XCTAssertEqual(view.alpha, 1) + } + + func testShowWithoutAnimationDuringAnimatedHide() { + let view = makeView() + + let hideCompleted = expectation(description: "Hide completed") + view.setIsHidden(true, animated: true) { _ in hideCompleted.fulfill() } + view.setIsHidden(false, animated: false) + wait(for: [hideCompleted], timeout: Self.timeout) + + XCTAssertFalse(view.isHidden) + XCTAssertEqual(view.alpha, 1) + } + + // MARK: - Helpers + + private func makeView() -> UIView { + let view = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 10)) + window.addSubview(view) + return view + } + + private func makeAnimator() -> UIViewPropertyAnimator { + UIViewPropertyAnimator(duration: 0.1, curve: .linear) + } + + /// Lets `configure` add animations to a new animator, then starts it. + private func startAnimator(_ configure: (UIViewPropertyAnimator) -> Void) -> XCTestExpectation { + let animator = makeAnimator() + configure(animator) + return start(animator) + } + + /// Starts `animator`, returning an expectation that is fulfilled after its other completion handlers have run. + private func start(_ animator: UIViewPropertyAnimator) -> XCTestExpectation { + let completed = expectation(description: "Animator completed") + animator.addCompletion { _ in completed.fulfill() } + animator.startAnimation() + return completed + } +}