Signal-iOS/Signal/ConversationView/CellViews/AudioMessagePlaybackRateView.swift
Adam Sharp 1a8c9fb5c6
Fix iOS 18 crash on launch due to offscreen rendering
Creating a UIImageView outside a view hierarchy now crashes on iOS 18. We can
avoid this offscreen rendering technique by removing UIImage.asTintedImage(),
configuring the tint colour and template rendering on the UIImage directly, and
rendering with UIGraphicsImageRenderer instead of lower-level Core Graphics
APIs.
2024-08-20 10:47:28 -04:00

446 lines
14 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
// MARK: - AudioPlaybackRate
enum AudioPlaybackRate: Float {
case slow = 0.5
case normal = 1
case fast = 1.5
case extraFast = 2
}
// MARK: - AudioMessagePlaybackRateView
class AudioMessagePlaybackRateView: ManualLayoutViewWithLayer {
private let threadUniqueId: String
private let audioAttachment: AudioAttachment
private let isIncoming: Bool
private var playbackRate: AudioPlaybackRate
private let label = CVLabel()
private let imageView = CVImageView()
init(
threadUniqueId: String,
audioAttachment: AudioAttachment,
playbackRate: AudioPlaybackRate,
isIncoming: Bool
) {
self.threadUniqueId = threadUniqueId
self.audioAttachment = audioAttachment
self.isIncoming = isIncoming
self.playbackRate = playbackRate
super.init(name: "AudioMessagePlaybackRateView")
// layoutBlocks get called once per frame change.
// no need to set one up per subview added, just
// have a single block that triggers an update to
// the frames of all the subviews.
addSubview(imageView)
addSubview(label, withLayoutBlock: { [weak self] _ in
self?.setSubviewFrames()
})
// start invisible
self.alpha = 0
self.backgroundColor = _backgroundColor
self.layer.cornerRadius = Constants.cornerRadius
Self.playbackRateLabelConfig(
playbackRate: playbackRate,
color: textColor
).applyForRendering(label: label)
self.imageView.image = Constants.image?.withTintColor(textColor, renderingMode: .alwaysTemplate)
}
// MARK: - Animating Changes
private var isVisible: Bool = false {
didSet {
self.alpha = isVisible ? 1 : 0
}
}
private var isAnimatingVisibility: Bool?
public func setVisibility(
_ visible: Bool,
animated: Bool = true,
completion: (() -> Void)? = nil
) {
// NOTE: can't use `isHidden` state because ManualStackView gets
// unhappy if one of its subviews hides. Use alpha instead.
guard isVisible != visible, isAnimatingVisibility != visible else {
completion?()
return
}
let fromScale = CATransform3DScale(
CATransform3DIdentity,
visible ? 0 : 1,
visible ? 0 : 1,
1
)
let toScale = CATransform3DScale(
CATransform3DIdentity,
visible ? 1 : 0,
visible ? 1 : 0,
1
)
layer.transform = toScale
let wrappedCompletion = {
self.isAnimatingVisibility = nil
self.isVisible = visible
completion?()
}
guard animated else {
wrappedCompletion()
return
}
// Make it visible so we can see the animation.
isVisible = true
isAnimatingVisibility = visible
CATransaction.begin()
layer.removeAnimation(forKey: Constants.animationName)
let animation = Self.createSpringAnimation()
animation.fillMode = .forwards
animation.fromValue = fromScale
animation.toValue = toScale
CATransaction.setCompletionBlock(wrappedCompletion)
layer.add(animation, forKey: Constants.animationName)
CATransaction.commit()
}
public func setPlaybackRate(
_ playbackRate: AudioPlaybackRate,
animated: Bool = true,
completion: (() -> Void)? = nil
) {
guard self.playbackRate != playbackRate else {
completion?()
return
}
self.playbackRate = playbackRate
let setContent = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.setSubviewFrames()
Self.playbackRateLabelConfig(
playbackRate: strongSelf.playbackRate,
color: strongSelf.textColor
).applyForRendering(label: strongSelf.label)
}
// Don't interrupt the appearance animation.
guard animated, self.isVisible, self.isAnimatingVisibility == nil else {
setContent()
completion?()
return
}
CATransaction.begin()
layer.removeAnimation(forKey: Constants.animationName)
let animation = Self.createSpringAnimation()
let fromScale = CATransform3DScale(
CATransform3DIdentity,
1,
1,
1
)
animation.fromValue = fromScale
let toScale = CATransform3DScale(
CATransform3DIdentity,
Constants.changeAnimationScale,
Constants.changeAnimationScale,
1
)
animation.toValue = toScale
animation.autoreverses = true
CATransaction.setCompletionBlock {
completion?()
}
layer.add(animation, forKey: Constants.animationName)
CATransaction.commit()
// Schedule the actual text update to happen halfway through,
// right at the reversal point.
DispatchQueue.main.asyncAfter(
deadline: .now() + Constants.animationDuration,
execute: setContent
)
return
}
private static func createSpringAnimation() -> CASpringAnimation {
let animation = CASpringAnimation(keyPath: "transform")
animation.damping = Constants.animationDamping
animation.stiffness = Constants.animationStiffness
animation.mass = Constants.animationMass
animation.duration = Constants.animationDuration
animation.speed = Constants.animationSpeed
return animation
}
// MARK: - Tapping
public func handleTap(
sender: UIGestureRecognizer,
itemModel: CVItemModel,
audioMessageViewDelegate: AudioMessageViewDelegate?
) -> Bool {
guard
let attachmentId = audioAttachment.attachmentStream?.attachmentStream.resourceId,
cvAudioPlayer.audioPlaybackState(forAttachmentId: attachmentId) == .playing
else {
return false
}
// Check that the tap is within the bounding box, but
// expand that to a minimum height/width if its too small.
let location = sender.location(in: self)
let tapTargetBounds = bounds.insetBy(
dx: -0.5 * max(0, Constants.minTapTargetSize - bounds.width),
dy: -0.5 * max(0, Constants.minTapTargetSize - bounds.height)
)
guard tapTargetBounds.contains(location) else {
return false
}
let newPlaybackRate = playbackRate.next
self.cvAudioPlayer.setPlaybackRate(newPlaybackRate.rawValue, forThreadUniqueId: threadUniqueId)
// Hold off updates until we animate the change.
let animationCompletion = audioMessageViewDelegate?.beginCellAnimation(
maximumDuration: Constants.maxAnimationDuration
)
let reloadGroup = DispatchGroup()
// First write the update to the db, this persists the change and ensures the
// reload we do afterwards pulls the updated rate.
reloadGroup.enter()
itemModel.databaseStorage.asyncWrite(
block: {
itemModel.threadAssociatedData.updateWith(
audioPlaybackRate: newPlaybackRate.rawValue,
updateStorageService: true,
transaction: $0
)
},
completion: {
reloadGroup.leave()
})
// Trigger the animation which also updates the playback rate value.
reloadGroup.enter()
setPlaybackRate(newPlaybackRate) {
reloadGroup.leave()
animationCompletion?()
}
reloadGroup.notify(queue: .main) { [weak audioMessageViewDelegate] in
// Once the animation _and_ the db update complete, issue a reload.
// This reloads _everything_, which is way overkill, but there's no easy way
// to reload only ThreadAssociatedData without a heavy refactor.
// This only happens on direct user input, anyway, so its probably not a
// big deal since it therefore only happens on human timescales.
audioMessageViewDelegate?.enqueueReloadWithoutCaches()
}
return true
}
// MARK: - Sizing
public static func measure(maxWidth: CGFloat) -> CGSize {
// Always size this view for the max playback rate size.
let labelConfig = Self.playbackRateLabelConfig(
playbackRate: AudioPlaybackRate.rateForLargestDisplayText,
color: .white // Color doesn't matter for sizing.
)
let nonLabelWidth = Constants.imageSize + Constants.margins.totalWidth
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxWidth - nonLabelWidth)
let height = Constants.margins.totalHeight + max(labelSize.height, Constants.imageSize)
return CGSize(
width: labelSize.width + nonLabelWidth,
height: height
)
}
// MARK: - Laying out subviews
private func setSubviewFrames() {
let labelConfig = Self.playbackRateLabelConfig(
playbackRate: playbackRate,
color: .white // Color doesn't matter for sizing.
)
let labelSize = CVText.measureLabel(
config: labelConfig,
maxWidth: bounds.width
)
let labelWidth = labelSize.width
let imageSize = Constants.imageSize
// We want the label and image as a whole to be centered,
// so pad each side with remaining width equally.
let contentWidth = labelWidth + imageSize
let sidePadding = (bounds.width - contentWidth) / 2
label.frame = CGRect(
x: sidePadding,
y: (bounds.height - labelSize.height) / 2,
width: labelWidth,
height: labelSize.height
)
imageView.frame = CGRect(
x: sidePadding + labelWidth,
y: (bounds.height - imageSize) / 2,
width: imageSize,
height: imageSize
)
}
// MARK: - Colors
open func makeBackgroundColor() -> UIColor {
isIncoming
? (Theme.isDarkThemeEnabled ? UIColor.ows_white : .ows_black).withAlphaComponent(0.08)
: UIColor.ows_whiteAlpha20
}
private lazy var _backgroundColor = { makeBackgroundColor() }()
open func makeTextColor() -> UIColor {
return isIncoming
? (Theme.isDarkThemeEnabled ? .ows_gray15 : .ows_gray60)
: .ows_white
}
private lazy var textColor: UIColor = { makeTextColor() }()
// MARK: - Configs
private static func playbackRateLabelConfig(
playbackRate: AudioPlaybackRate,
color: UIColor
) -> CVLabelConfig {
let text = playbackRate.displayText
// Limit the max font size to avoid overlap.
var font = Constants.font
if font.pointSize > Constants.maxFontSize {
font = font.withSize(Constants.maxFontSize)
}
font = font.semibold()
return CVLabelConfig.unstyledText(
text,
font: font,
textColor: color,
textAlignment: .right
)
}
fileprivate enum Constants {
static let cornerRadius: CGFloat = 6
static var font: UIFont { UIFont.dynamicTypeFootnote }
static let maxFontSize: CGFloat = 20
static var imageSize: CGFloat {
switch UIApplication.shared.preferredContentSizeCategory {
case .extraSmall, .small, .medium, .large, .extraLarge:
return 10
default:
return 16
}
}
static var image: UIImage? {
switch UIApplication.shared.preferredContentSizeCategory {
case .extraSmall, .small, .medium, .large, .extraLarge:
return UIImage(named: "x-extra-small")
default:
return UIImage(named: "x-compact")
}
}
static let margins = UIEdgeInsets(hMargin: 8, vMargin: 2)
static let animationName = "scale"
static let animationDuration: TimeInterval = 0.15
static let animationDamping: CGFloat = 1.15
static let animationStiffness: CGFloat = 100
static let animationMass: CGFloat = 1
static let animationSpeed: Float = 1
static let changeAnimationScale: CGFloat = 1.3
static var maxAnimationDuration: TimeInterval {
return animationDuration * 2 // 2x for autoreverse
}
static let minTapTargetSize: CGFloat = 44
}
}
// MARK: - AudioPlaybackRate extension
extension AudioPlaybackRate {
init(rawValue: Float) {
switch rawValue {
case _ where rawValue <= 0.5:
self = .slow
case _ where rawValue < 1.5:
self = .normal
case _ where rawValue < 2:
self = .fast
default:
self = .extraFast
}
}
var next: AudioPlaybackRate {
switch self {
case .slow:
return .normal
case .normal:
return .fast
case .fast:
return .extraFast
case .extraFast:
return .slow
}
}
var displayText: String {
// Instead of dealing with float formatting, just
// hardcode since there's only 4 cases anyway.
switch self {
case .slow:
return LocalizationNotNeeded(".5")
case .normal:
return LocalizationNotNeeded("1")
case .fast:
return LocalizationNotNeeded("1.5")
case .extraFast:
return LocalizationNotNeeded("2")
}
}
static var rateForLargestDisplayText: AudioPlaybackRate {
return .fast
}
}