TM-SGNL-iOS/Signal/ConversationView/CellViews/GiftBadgeView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

814 lines
32 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import BonMot
import Foundation
import Lottie
import QuartzCore
import SignalServiceKit
import SignalUI
class GiftBadgeView: ManualStackView {
struct State {
enum Badge {
// The badge isn't loaded. Calling the block will load it.
case notLoaded(() -> Promise<Void>)
// The badge is loaded. The associated value is the badge.
case loaded(ProfileBadge)
// No badge was found for the level in the gift.
case notFound
}
let badge: Badge
let messageUniqueId: String
let timeRemainingText: String
let otherUserShortName: String
let redemptionState: OWSGiftBadgeRedemptionState
let isIncoming: Bool
let conversationStyle: ConversationStyle
}
// The outerStack contains the details (innerStack) & redeem button.
private static let measurementKey_outerStack = "GiftBadgeView.measurementKey_outerStack"
private static var outerStackConfig: CVStackViewConfig {
CVStackViewConfig(
axis: .vertical,
alignment: .fill,
spacing: 15,
layoutMargins: .init(hMargin: 0, vMargin: 8)
)
}
// The innerStack contains the badge icon & labels (labelStack).
private let innerStack = ManualStackView(name: "GiftBadgeView.innerStack")
private static let measurementKey_innerStack = "GiftBadgeView.measurementKey_innerStack"
private static var innerStackConfig: CVStackViewConfig {
CVStackViewConfig(
axis: .horizontal,
alignment: .center,
spacing: 12,
layoutMargins: .init(hMargin: 4, vMargin: 0)
)
}
// The labelStack contains "Gift Badge" & "N days remaining".
private let labelStack = ManualStackView(name: "GiftBadgeView.labelStack")
private static let measurementKey_labelStack = "GiftBadgeView.measurementKey_labelStack"
private static var labelStackConfig: CVStackViewConfig {
CVStackViewConfig(axis: .vertical, alignment: .leading, spacing: 4, layoutMargins: .zero)
}
private let titleLabel = CVLabel()
private static func titleLabelConfig(for state: State) -> CVLabelConfig {
let textFormat: String
if state.isIncoming {
textFormat = OWSLocalizedString(
"DONATION_ON_BEHALF_OF_A_FRIEND_RECEIVED_TITLE_FORMAT",
comment: "You received a donation from a friend. This is the title of that message in the chat. Embeds {{short contact name}}."
)
} else {
textFormat = OWSLocalizedString(
"DONATION_ON_BEHALF_OF_A_FRIEND_SENT_TITLE_FORMAT",
comment: "You sent a donation to a friend. This is the title of that message in the chat. Embeds {{short contact name}}."
)
}
let text = String(format: textFormat, state.otherUserShortName)
let textColor = state.conversationStyle.bubbleTextColor(isIncoming: state.isIncoming)
return CVLabelConfig.unstyledText(
text,
font: .dynamicTypeBody,
textColor: textColor,
numberOfLines: 0
)
}
static func timeRemainingText(for expirationDate: Date) -> String {
let timeRemaining = expirationDate.timeIntervalSinceNow
guard timeRemaining > 0 else {
return OWSLocalizedString(
"DONATE_ON_BEHALF_OF_A_FRIEND_CHAT_EXPIRED",
comment: "If a donation badge has been sent, indicates that it's expired and can no longer be redeemed. This is shown in the chat."
)
}
return self.localizedDurationText(for: timeRemaining)
}
private static func localizedDurationText(for timeRemaining: TimeInterval) -> String {
// If there's less than a minute remaining, report "1 minute remaining".
// Otherwise, we'll say "0 minutes remaining", which implies the badge has
// expired, even though it hasn't.
let normalizedTimeRemaining = max(timeRemaining, 60)
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.minute, .hour, .day]
formatter.maximumUnitCount = 1
formatter.unitsStyle = .full
formatter.includesTimeRemainingPhrase = true
guard let result = formatter.string(from: normalizedTimeRemaining) else {
owsFailDebug("Couldn't format time until badge expiration")
return ""
}
return result
}
private let timeRemainingLabel = CVLabel()
private static func timeRemainingLabelConfig(for state: State) -> CVLabelConfig {
let textColor = state.conversationStyle.bubbleSecondaryTextColor(isIncoming: state.isIncoming)
return CVLabelConfig.unstyledText(
state.timeRemainingText,
font: .dynamicTypeSubheadline,
textColor: textColor,
numberOfLines: 0
)
}
// Use a stack with one item to get layout & padding for free.
public let buttonStack = ManualStackViewWithLayer(name: "GiftBadgeView.buttonStack")
private static let measurementKey_buttonStack = "GiftBadgeView.measurementKey_buttonStack"
private static var buttonStackConfig: CVStackViewConfig {
CVStackViewConfig(
axis: .horizontal,
alignment: .center,
spacing: 0,
layoutMargins: UIEdgeInsets(margin: 10)
)
}
private func redeemButtonBackgroundColor(for state: State) -> UIColor {
if state.isIncoming {
return state.conversationStyle.isDarkThemeEnabled ? .ows_gray60 : .ows_whiteAlpha80
} else {
return .ows_whiteAlpha70
}
}
// This is a label, not a button, to remain compatible with the hit testing code.
private let redeemButtonLabel = CVLabel()
private static func redeemButtonLabelConfig(for state: State) -> CVLabelConfig {
let font: UIFont = .dynamicTypeBody.semibold()
let color = self.redeemButtonTextColor(for: state)
return CVLabelConfig(
text: .attributedText(Self.redeemButtonText(for: state, font: font)),
displayConfig: .forUnstyledText(font: font, textColor: color),
font: font,
textColor: color,
lineBreakMode: .byTruncatingTail,
textAlignment: .center
)
}
private static func redeemButtonTextColor(for state: State) -> UIColor {
if state.isIncoming {
return state.conversationStyle.bubbleTextColorIncoming
} else {
return .ows_gray90
}
}
private static func redeemButtonText(for state: State, font: UIFont) -> NSAttributedString {
let nonAttributedString: String
if state.isIncoming {
// TODO: (GB) Alter this value based on whether or not the badge has been redeemed.
switch state.redemptionState {
case .opened:
owsFailDebug("Only outgoing gifts can be permanently opened")
fallthrough
case .pending:
nonAttributedString = CommonStrings.redeemGiftButton
case .redeemed:
let attrString = NSMutableAttributedString()
attrString.appendTemplatedImage(named: Theme.iconName(.checkCircle), font: font)
attrString.append("\u{2004}\u{2009}")
attrString.append(OWSLocalizedString(
"DONATION_ON_BEHALF_OF_A_FRIEND_BADGE_REDEEMED",
comment: "Label for a button to see details about a badge you've already redeemed, received as a result of a donation from a friend. This text is shown next to a check mark."
))
return attrString
}
} else {
nonAttributedString = OWSLocalizedString(
"DONATION_ON_BEHALF_OF_A_FRIEND_VIEW",
comment: "A button shown on a donation message you send, to view additional details about the badge that was sent."
)
}
return NSAttributedString(string: nonAttributedString)
}
private let badgeView = CVImageView()
private static let badgeSize: CGFloat = 64
struct ActivityIndicator {
var name: String
var view: LottieAnimationView
}
private var _activityIndicator: ActivityIndicator?
private func activityIndicator(for state: State) -> LottieAnimationView {
let animationName: String
if state.isIncoming && !state.conversationStyle.isDarkThemeEnabled {
animationName = "indeterminate_spinner_blue"
} else {
animationName = "indeterminate_spinner_white"
}
if let activityIndicator = self._activityIndicator, activityIndicator.name == animationName {
return activityIndicator.view
}
let view = LottieAnimationView(name: animationName)
view.backgroundBehavior = .pauseAndRestore
view.loopMode = .loop
view.contentMode = .center
self._activityIndicator = ActivityIndicator(name: animationName, view: view)
return view
}
private(set) var giftWrap: GiftWrap?
override func reset() {
super.reset()
self.innerStack.reset()
self.labelStack.reset()
self.buttonStack.reset()
self.titleLabel.text = nil
self.timeRemainingLabel.text = nil
self.redeemButtonLabel.text = nil
self.badgeView.image = nil
self._activityIndicator?.view.stop()
let allSubviews: [UIView?] = [
self.innerStack,
self.labelStack,
self.buttonStack,
self.titleLabel,
self.timeRemainingLabel,
self.redeemButtonLabel,
self.badgeView,
self._activityIndicator?.view
]
for subview in allSubviews {
subview?.removeFromSuperview()
}
}
func configureForRendering(state: State, cellMeasurement: CVCellMeasurement, componentDelegate: CVComponentDelegate) {
Self.redeemButtonLabelConfig(for: state).applyForRendering(label: self.redeemButtonLabel)
self.buttonStack.backgroundColor = self.redeemButtonBackgroundColor(for: state)
self.buttonStack.addLayoutBlock { v in
v.layer.cornerRadius = 8
v.layer.masksToBounds = true
}
self.buttonStack.configure(
config: Self.buttonStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_buttonStack,
subviews: [self.redeemButtonLabel]
)
let innerStackSubviews: [UIView]
switch state.badge {
case .notLoaded(let loadPromise):
loadPromise().done { [weak componentDelegate] in
componentDelegate?.enqueueReload()
}.cauterize()
// TODO: (GB) If an error occurs, we'll be stuck with a spinner.
let activityIndicator = self.activityIndicator(for: state)
activityIndicator.play()
innerStackSubviews = [activityIndicator]
self.buttonStack.alpha = 0.5
case .notFound:
// Show the same UI as we do when loading.
let activityIndicator = self.activityIndicator(for: state)
activityIndicator.play()
innerStackSubviews = [activityIndicator]
self.buttonStack.alpha = 0.5
case .loaded(let profileBadge):
self.badgeView.image = profileBadge.assets?.universal64
Self.titleLabelConfig(for: state).applyForRendering(label: self.titleLabel)
Self.timeRemainingLabelConfig(for: state).applyForRendering(label: self.timeRemainingLabel)
self.labelStack.configure(
config: Self.labelStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_labelStack,
subviews: [self.titleLabel, self.timeRemainingLabel]
)
innerStackSubviews = [self.badgeView, self.labelStack]
self.buttonStack.alpha = 1.0
}
self.innerStack.configure(
config: Self.innerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_innerStack,
subviews: innerStackSubviews
)
self.configure(
config: Self.outerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerStack,
subviews: [self.innerStack, self.buttonStack]
)
if state.wrapState == .unwrapped || !componentDelegate.willWrapGift(state.messageUniqueId) {
self.giftWrap = nil
} else if self.giftWrap?.isIncoming != state.isIncoming {
// If `giftWrap` is nil, we'll also fall into this case.
self.giftWrap = GiftWrap(isIncoming: state.isIncoming)
}
}
/**
* Calculates the maxWidth available within nested stack views.
*
* If a view is contained within a stack view, then its available width is
* reduced by the stack view's horizontal margins. If it's placed within
* multiple stack views, its available width is reduced by each of the
* stack view's margins.
*
* The `subtracting` parameter allows the caller to account for space
* consumed by siblings.
*/
private static func maxWidthForView(
placedWithin stackConfigs: [CVStackViewConfig],
startingAt maxWidth: CGFloat,
subtracting value: CGFloat
) -> CGFloat {
return maxWidth - value - stackConfigs.reduce(0) { $0 + $1.layoutMargins.totalWidth }
}
static func measurement(for state: State, maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
owsAssertDebug(maxWidth > 0)
// NOTE: We don't alter the measurement when showing the loading animation.
// This ensures that the size of the bubble doesn't shift once the badge
// has finished loading.
let badgeViewSize = CGSize(square: self.badgeSize)
let outerStackConfig = self.outerStackConfig
let innerStackConfig = self.innerStackConfig
let labelStackConfig = self.labelStackConfig
let buttonStackConfig = self.buttonStackConfig
// The space for labels is reduced by all stacks & the badgeView.
let labelMaxWidth = self.maxWidthForView(
placedWithin: [labelStackConfig, innerStackConfig, outerStackConfig],
startingAt: maxWidth,
subtracting: badgeViewSize.width + innerStackConfig.spacing
)
let titleLabelSize = self.titleLabelConfig(for: state).measure(maxWidth: labelMaxWidth)
let timeRemainingLabelSize = self.timeRemainingMeasurement(for: state, maxWidth: labelMaxWidth)
let labelStackMeasurement = ManualStackView.measure(
config: labelStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_labelStack,
subviewInfos: [titleLabelSize.asManualSubviewInfo, timeRemainingLabelSize.asManualSubviewInfo]
)
let innerStackMeasurement = ManualStackView.measure(
config: innerStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_innerStack,
subviewInfos: [
badgeViewSize.asManualSubviewInfo,
labelStackMeasurement.measuredSize.asManualSubviewInfo
]
)
let buttonMaxWidth = self.maxWidthForView(
placedWithin: [buttonStackConfig, outerStackConfig],
startingAt: maxWidth,
subtracting: 0
)
let redeemButtonLabelSize = self.redeemButtonLabelConfig(for: state).measure(maxWidth: buttonMaxWidth)
let buttonStackMeasurement = ManualStackView.measure(
config: buttonStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_buttonStack,
subviewInfos: [redeemButtonLabelSize.asManualSubviewInfo]
)
let outerStackMeasurement = ManualStackView.measure(
config: outerStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_outerStack,
subviewInfos: [
innerStackMeasurement.measuredSize.asManualSubviewInfo,
buttonStackMeasurement.measuredSize.asManualSubviewInfo
]
)
return outerStackMeasurement.measuredSize
}
private static func timeRemainingMeasurement(for state: State, maxWidth: CGFloat) -> CGSize {
let labelConfig = self.timeRemainingLabelConfig(for: state)
var labelSize = labelConfig.measure(maxWidth: maxWidth)
// The time remaining label often defines the overall width/aspect ratio of
// the gift message. The "Expired" label is typically the shortest, which
// leads to weird aspect ratios. Use the maximum of a few sizes to try and
// give the badge a bit more consistency, even though this may not be
// perfect across all languages.
let timeRemainingCandidates: [TimeInterval] = [59*kMinuteInterval, 23*kHourInterval, 59*kDayInterval]
for timeRemaining in timeRemainingCandidates {
let candidateConfig = CVLabelConfig.unstyledText(
self.localizedDurationText(for: timeRemaining),
font: labelConfig.font,
textColor: labelConfig.textColor,
// Only consider the first line for these alternative values. This (a)
// ensures that we don't reserve space for the second line unless the value
// we're going to show needs two lines and (b) still maintains a roughly
// constant overall bubble width.
lineBreakMode: .byTruncatingTail
)
let candidateSize = candidateConfig.measure(maxWidth: maxWidth)
labelSize.width = max(labelSize.width, candidateSize.width)
}
return labelSize
}
func animateUnwrap() {
self.giftWrap?.animateUnwrap()
self.giftWrap = nil
}
}
// MARK: - Wrapping View
private enum WrapState {
case wrapped
case unwrapped
}
private extension GiftBadgeView.State {
var wrapState: WrapState {
switch self.redemptionState {
case .redeemed, .opened:
return .unwrapped
case .pending:
return .wrapped
}
}
}
class GiftWrap {
/// The rootView for use in the conversation view.
let rootView: ManualLayoutView
/// The view whose edge should match that of the related bubble.
var bubbleViewPartner: OWSBubbleViewPartner { self.giftWrapView.wrappingView }
/// The actual view containing the gift wrapping. This view is transferred
/// from the conversation view to the window for the "unwrap" animation.
private let giftWrapView: GiftWrapView
fileprivate let isIncoming: Bool
static let shakeAnimationDuration: CGFloat = 0.8
fileprivate init(isIncoming: Bool) {
let giftWrapView = GiftWrapView()
// Don't let the subview wrapper touch `GiftWrapView` -- we want this view
// to be pristine for when we reuse it during the unwrap animation.
let view = UIView()
view.addSubview(giftWrapView)
giftWrapView.autoPinEdgesToSuperviewEdges()
self.giftWrapView = giftWrapView
self.rootView = .wrapSubviewUsingIOSAutoLayout(view, wrapperName: "giftWrapWrapper")
self.isIncoming = isIncoming
}
func animateShake() {
self.giftWrapView.animateShake()
}
fileprivate func animateUnwrap() {
let giftWrapView = self.giftWrapView
// If the view isn't attached to a window, we can't show the unwrap
// animation. Since this happens when the user taps an optional button,
// crashing is a reasonable course of action.
guard let window = giftWrapView.window else {
owsFail("no window for unwrap animation")
}
// Clear the bubble view host -- this is necessary as part of detaching
// this view from the conversation view rendering pipeline. When this link
// is removed, the shape of the wrapping view is no longer updated by the
// bubble. This ensures it doesn't change if the bubble is reused.
giftWrapView.wrappingView.setBubbleViewHost(nil)
// Figure out where the view is currently positioned in the window. We'll
// assign this as the initial starting point of the animation.
let frame = giftWrapView.convert(giftWrapView.bounds, to: window)
let view = UnwrapAnimationView(giftWrapView)
view.frame = frame
window.addSubview(view)
view.animateUnwrap(isIncoming: self.isIncoming)
UIImpactFeedbackGenerator().impactOccurred()
}
}
private class GiftWrapView: UIView {
let wrappingContainer = UIView()
let wrappingView = OWSBubbleShapeView(mode: .clip)
let bowView = UIImageView(image: UIImage(named: "gift-bow"))
init() {
super.init(frame: .zero)
let wrapWidth: CGFloat = 16
let wrappingContainer = self.wrappingContainer
self.addSubview(wrappingContainer)
wrappingContainer.autoPinEdgesToSuperviewEdges()
let wrappingView = self.wrappingView
wrappingView.backgroundColor = .ows_accentBlue
wrappingContainer.addSubview(wrappingView)
let horizontalWrap = UIView()
horizontalWrap.backgroundColor = .ows_white
wrappingContainer.addSubview(horizontalWrap)
horizontalWrap.autoSetDimension(.height, toSize: wrapWidth)
horizontalWrap.autoPinWidthToSuperview()
horizontalWrap.autoCenterInSuperview()
let verticalWrap = UIView()
verticalWrap.backgroundColor = .ows_white
wrappingContainer.addSubview(verticalWrap)
verticalWrap.autoSetDimension(.width, toSize: wrapWidth)
verticalWrap.autoPinHeightToSuperview()
verticalWrap.autoCenterInSuperview()
// The bowView is not a subview of wrappingContainer. This allows it to
// animate separately.
let bowView = self.bowView
self.addSubview(bowView)
bowView.autoCenterInSuperview()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
// This view doesn't support Auto Layout.
self.wrappingView.frame = self.wrappingContainer.bounds
}
func animateShake() {
let shakeCount = 3
let shakeMagnitude: CGFloat = 10
let bowDelay: CGFloat = 0.04
let duration: CGFloat = GiftWrap.shakeAnimationDuration - bowDelay
self.animateShake(
for: self.wrappingContainer,
shakeCount: shakeCount,
shakeMagnitude: shakeMagnitude,
startDelay: 0,
duration: duration
)
// The bow animates with a 40ms delay compared to the wrapping.
self.animateShake(
for: self.bowView,
shakeCount: shakeCount,
shakeMagnitude: shakeMagnitude * 0.5,
startDelay: bowDelay,
duration: duration
)
}
/// Build a CAAnimation that shakes the view back and forth.
///
/// - Parameters:
/// - shakeCount: The number of times the view should be shaken. One shake
/// starts at the middle, moves left, moves right, and then moves back to
/// the middle.
///
/// - shakeMagnitude: How far from its original position the view should
/// deviate. In circle terms, this is the radius, not the diameter.
///
/// - startDelay: How long to delay the animation before starting.
///
/// - duration: How long the animation should last. The total duration is
/// `startDelay + duration`.
///
private func animateShake(
for view: UIView,
shakeCount: Int,
shakeMagnitude: CGFloat,
startDelay: CGFloat,
duration: CGFloat
) {
// Build the equally-spaced positions for the animation.
var values = [CGFloat]()
for _ in 0..<shakeCount {
values.append(contentsOf: [0, -shakeMagnitude, 0, shakeMagnitude])
}
values.append(0)
// Build equally-spaced keyTimes on the [0, 1] scale.
let totalDuration = startDelay + duration
let firstKeyTime = startDelay / totalDuration
let deltaKeyTime = (1 - firstKeyTime) / CGFloat(values.count - 1)
var keyTimes = [NSNumber]()
for idx in 0..<values.count {
keyTimes.append(NSNumber(value: firstKeyTime + deltaKeyTime * CGFloat(idx)))
}
let animation = CAKeyframeAnimation(keyPath: "transform.translation.x")
animation.values = values
animation.keyTimes = keyTimes
animation.duration = totalDuration
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
view.layer.add(animation, forKey: "shake")
}
}
/// Provides a fixed-size container for the unwrap animation.
///
/// The contents of this view are transferred from the view used in the
/// conversation when the animation starts.
///
/// When the animation is done, this view removes itself from its superview.
private class UnwrapAnimationView: UIView, CAAnimationDelegate {
private let bowView: UIView
init(_ containerView: GiftWrapView) {
self.bowView = containerView.bowView
super.init(frame: .zero)
// When animating, don't capture any touches.
self.isUserInteractionEnabled = false
self.addSubview(containerView)
containerView.autoPinEdgesToSuperviewEdges()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Builds an animation for unwrapping a gift.
///
func animateUnwrap(isIncoming: Bool) {
let animationKey = "unwrap"
// Flip the horizontal and rotation elements for outgoing gifts.
let directionMultipler: CGFloat = isIncoming ? 1.0 : -1.0
// The bow rotates back and forth.
self.bowView.layer.animateRotation(animationKey: animationKey, duration: 1.8, keyFrames: [
(0.000, 0 * directionMultipler),
(0.050, 3 * directionMultipler),
(0.220, -3 * directionMultipler),
(0.400, 3 * directionMultipler),
(1.030, -8 * directionMultipler),
(1.400, 5 * directionMultipler),
(1.800, 5 * directionMultipler)
])
// The bubble rotates back and forth, opposite from the bow.
self.layer.animateRotation(animationKey: animationKey, duration: 1.8, keyFrames: [
(0.000, 0 * directionMultipler),
(0.400, 0 * directionMultipler),
(1.030, 8 * directionMultipler),
(1.400, -5 * directionMultipler),
(1.800, -5 * directionMultipler)
])
// The vertical movement is "approximately gravity". As a result, the path
// is closer to a parabola than a standard easeInEaseOut curve (that curve
// would have the fastest movement at the top of the arc). This gravity
// motion is roughly approximated by an easeOutEaseIn curve (note the
// flipped order of out/in), but the magnitude of the easing has been
// tweaked to mimic the spec.
self.layer.animateTranslation(animationKey: animationKey, coordinateKey: "y", duration: 1.8, keyFrames: [
(0.400, 0, .init(name: .linear)),
(0.730, -74, .init(controlPoints: 0.00, 0.00, 0.25, 1.00)),
(1.800, 1366, .init(controlPoints: 0.90, 0.00, 1.00, 1.00))
]).delegate = self
// The horizontal movement uses easeInEaseOut, split across the two phases.
self.layer.animateTranslation(animationKey: animationKey, coordinateKey: "x", duration: 1.8, keyFrames: [
(0.400, 0 * directionMultipler, .init(name: .linear)),
(0.730, 11 * directionMultipler, .init(name: .easeIn)),
(1.400, 18 * directionMultipler, .init(name: .easeOut)),
(1.800, 18 * directionMultipler, .init(name: .linear))
])
// The vertical motion uses a constant of 1366, which is (currently) the
// tallest iPad. As a result, all devices use the same shape and *velocity*
// for the animation. Most of the time, however, the wrapping view will be
// offscreen before the total duration has elapsed, so you'll only see part
// of the animation. This is more natural than animating faster or slower
// depending on how far the gift wrap needs to move.
assert(self.window!.bounds.size.height <= 1366)
// Set the final position off the screen so that the view doesn't "jump
// back" before it gets removed.
self.layer.setAffineTransform(
CGAffineTransform(translationX: 18 * directionMultipler, y: 1366)
)
}
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
self.removeFromSuperview()
}
}
private extension CALayer {
/// Animates a rotation through various key frames.
///
/// This animation uses cubic interpolation, which smooths the changes in
/// direction.
///
/// - Parameters:
/// - animationKey: A unique key to associate with the animation. A
/// rotation-specific key is appended.
///
/// - duration: The total duration of the animation, in seconds.
///
/// - keyFrames: The key frames for the animation. The first key frame
/// should have a time of `0`, and the last key frame should have a time
/// of `duration`.
///
func animateRotation(
animationKey: String,
duration: CFTimeInterval,
keyFrames: [(keyTime: CFTimeInterval, degrees: CGFloat)]
) {
let keyPath = "transform.rotation"
let animation = CAKeyframeAnimation(keyPath: keyPath)
let values = keyFrames.map { $0.degrees * .pi / 180 }
animation.values = values
animation.keyTimes = keyFrames.map { NSNumber(value: $0.keyTime / duration) }
animation.calculationMode = .cubic
animation.duration = duration
animation.fillMode = .forwards
self.add(animation, forKey: "\(animationKey).\(keyPath)")
}
/// Animates a translation through various key frames.
///
/// Each key frame specifies its own timing function. The initial position
/// is assumed to be `0` at 0 seconds, and each timing function argument
/// applies to the prior point and the current point.
///
/// - Parameters:
/// - animationKey: A unique key to associated with the animation. A
/// translation-specific key is appended.
///
/// - coordinateKey: The coordinate whose value should be animated. Should
/// be "x" or "y".
///
/// - duration: The total duration of the animation, in seconds.
///
/// - keyFrames: The key frames for the animation. The first key frame
/// should have a time of `0`, and the last key frame should have a time
/// of `duration`.
///
@discardableResult
func animateTranslation(
animationKey: String,
coordinateKey: String,
duration: CFTimeInterval,
keyFrames: [(keyTime: CFTimeInterval, value: CGFloat, timingFunction: CAMediaTimingFunction)]
) -> CAAnimation {
let keyPath = "transform.translation.\(coordinateKey)"
let animation = CAKeyframeAnimation(keyPath: keyPath)
animation.values = [0 as CGFloat] + keyFrames.map { $0.value }
animation.keyTimes = [NSNumber(0)] + keyFrames.map { NSNumber(value: $0.keyTime / duration) }
animation.timingFunctions = keyFrames.map { $0.timingFunction }
animation.duration = duration
self.add(animation, forKey: "\(animationKey).\(keyPath)")
return animation
}
}