Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Signal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
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 = "<group>"; };
A5E7C674248C5442007C949A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = translations/en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
A65D45A563D649F88E03BAAD /* UIKit+AnimationsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIKit+AnimationsTest.swift"; sourceTree = "<group>"; };
A780A90C5DDC028939E97635 /* LowDiskSpaceManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LowDiskSpaceManager.swift; sourceTree = "<group>"; };
AB0C401B2E5BADCE00A1B2C3 /* NotificationSettingsBadgeCountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsBadgeCountViewController.swift; sourceTree = "<group>"; };
B3F39202F831935AAE1C5F54 /* Pods_SignalUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SignalUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
Expand Down
142 changes: 112 additions & 30 deletions SignalUI/UIKitExtensions/UIKit+Animations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
Loading