Исправлен рендеринг voice blob — убран shapeLayer.bounds, уменьшен размер до 56pt
This commit is contained in:
@@ -246,7 +246,11 @@ private final class BlobLayer {
|
|||||||
|
|
||||||
func updateBounds(_ size: CGSize) {
|
func updateBounds(_ size: CGSize) {
|
||||||
boundsSize = size
|
boundsSize = size
|
||||||
shapeLayer.bounds = CGRect(origin: .zero, size: size)
|
// DON'T set shapeLayer.bounds — leave at default (0,0,0,0).
|
||||||
|
// Telegram BlobNode.layout(): shapeLayer has zero-sized bounds + position at center.
|
||||||
|
// With zero bounds, local (0,0) maps to the position point = view center.
|
||||||
|
// Blob paths are centered at (0,0), so they render at the view's center. Correct!
|
||||||
|
// Setting bounds to (0,0,68,68) shifts local (0,0) to the view's top-left. Wrong!
|
||||||
shapeLayer.position = CGPoint(x: size.width / 2, y: size.height / 2)
|
shapeLayer.position = CGPoint(x: size.width / 2, y: size.height / 2)
|
||||||
|
|
||||||
if isCircle {
|
if isCircle {
|
||||||
|
|||||||
@@ -1278,12 +1278,14 @@ extension ComposerView: RecordingMicButtonDelegate {
|
|||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
inputContainer.alpha = 1
|
inputContainer.alpha = 1
|
||||||
|
inputContainer.clipsToBounds = false // RecordMore floats above panel
|
||||||
attachButton.alpha = 0
|
attachButton.alpha = 0
|
||||||
micButton.alpha = 0
|
micButton.alpha = 0
|
||||||
micGlass.alpha = 0
|
micGlass.alpha = 0
|
||||||
micIconView?.alpha = 0
|
micIconView?.alpha = 0
|
||||||
bringSubviewToFront(inputContainer)
|
bringSubviewToFront(inputContainer)
|
||||||
} else {
|
} else {
|
||||||
|
inputContainer.clipsToBounds = true
|
||||||
replyBar.alpha = isReplyVisible ? 1 : 0
|
replyBar.alpha = isReplyVisible ? 1 : 0
|
||||||
bringSubviewToFront(micButton)
|
bringSubviewToFront(micButton)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ final class MessageVoiceView: UIView {
|
|||||||
private var isOutgoing = false
|
private var isOutgoing = false
|
||||||
private var isShowingPause = false // tracks Lottie visual state
|
private var isShowingPause = false // tracks Lottie visual state
|
||||||
private var totalDuration: TimeInterval = 0 // original duration for label reset
|
private var totalDuration: TimeInterval = 0 // original duration for label reset
|
||||||
private var blobView: VoiceBlobView? // pulsing ring around play button during playback
|
/// Center of play button in this view's coordinate space (for external blob positioning).
|
||||||
|
var playButtonCenter: CGPoint { playButton.center }
|
||||||
|
|
||||||
// MARK: - Layout Constants (Telegram exact: ChatMessageInteractiveFileNode)
|
// MARK: - Layout Constants (Telegram exact: ChatMessageInteractiveFileNode)
|
||||||
|
|
||||||
@@ -136,11 +137,6 @@ final class MessageVoiceView: UIView {
|
|||||||
playButton.setImage(nil, for: .normal)
|
playButton.setImage(nil, for: .normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove blob from previous cell reuse
|
|
||||||
blobView?.stopAnimating()
|
|
||||||
blobView?.removeFromSuperview()
|
|
||||||
blobView = nil
|
|
||||||
|
|
||||||
self.totalDuration = duration
|
self.totalDuration = duration
|
||||||
|
|
||||||
// Decode waveform from preview
|
// Decode waveform from preview
|
||||||
@@ -202,9 +198,6 @@ final class MessageVoiceView: UIView {
|
|||||||
}
|
}
|
||||||
waveformView.enableScrubbing = isPlaying
|
waveformView.enableScrubbing = isPlaying
|
||||||
|
|
||||||
// Blob animation (Telegram: VoiceBlobNode around play button during playback)
|
|
||||||
updateBlobState(isPlaying: isPlaying)
|
|
||||||
|
|
||||||
let shouldShowPause = isPlaying
|
let shouldShowPause = isPlaying
|
||||||
guard shouldShowPause != isShowingPause else { return }
|
guard shouldShowPause != isShowingPause else { return }
|
||||||
isShowingPause = shouldShowPause
|
isShowingPause = shouldShowPause
|
||||||
@@ -226,51 +219,6 @@ final class MessageVoiceView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Blob Animation (Telegram: ChatMessageInteractiveFileNode lines 1843-1866)
|
|
||||||
|
|
||||||
private func updateBlobState(isPlaying: Bool) {
|
|
||||||
if isPlaying {
|
|
||||||
if blobView == nil {
|
|
||||||
// Telegram params: maxLevel=0.3, no small blob, medium 0.7-0.8, big 0.8-0.9
|
|
||||||
let blob = VoiceBlobView(
|
|
||||||
frame: .zero,
|
|
||||||
maxLevel: 0.3,
|
|
||||||
smallBlobRange: (min: 0, max: 0),
|
|
||||||
mediumBlobRange: (min: 0.7, max: 0.8),
|
|
||||||
bigBlobRange: (min: 0.8, max: 0.9)
|
|
||||||
)
|
|
||||||
// Frame: 12pt larger on each side than play button (68×68)
|
|
||||||
let blobInset: CGFloat = -12
|
|
||||||
blob.frame = playButton.frame.insetBy(dx: blobInset, dy: blobInset)
|
|
||||||
|
|
||||||
// Even-odd mask to cut out the inner play button circle
|
|
||||||
let maskLayer = CAShapeLayer()
|
|
||||||
let fullRect = CGRect(origin: .zero, size: blob.bounds.size)
|
|
||||||
let path = UIBezierPath(rect: fullRect)
|
|
||||||
let innerDiameter = playButtonSize
|
|
||||||
let innerOrigin = CGPoint(x: (fullRect.width - innerDiameter) / 2,
|
|
||||||
y: (fullRect.height - innerDiameter) / 2)
|
|
||||||
let innerRect = CGRect(origin: innerOrigin, size: CGSize(width: innerDiameter, height: innerDiameter))
|
|
||||||
path.append(UIBezierPath(ovalIn: innerRect))
|
|
||||||
maskLayer.path = path.cgPath
|
|
||||||
maskLayer.fillRule = .evenOdd
|
|
||||||
blob.layer.mask = maskLayer
|
|
||||||
|
|
||||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
|
||||||
let colors = RosettaColors.Voice.colors(isOutgoing: isOutgoing, isDark: isDark)
|
|
||||||
blob.setColor(colors.playButtonBg)
|
|
||||||
|
|
||||||
insertSubview(blob, belowSubview: playButton)
|
|
||||||
blobView = blob
|
|
||||||
}
|
|
||||||
blobView?.startAnimating()
|
|
||||||
// Gentle fixed pulse (VoiceMessagePlayer doesn't expose audio level metering)
|
|
||||||
blobView?.updateLevel(0.2)
|
|
||||||
} else {
|
|
||||||
blobView?.stopAnimating()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates duration label to show elapsed time during playback, total when stopped.
|
/// Updates duration label to show elapsed time during playback, total when stopped.
|
||||||
func updateDurationDuringPlayback(currentTime: TimeInterval, totalDuration: TimeInterval, isPlaying: Bool) {
|
func updateDurationDuringPlayback(currentTime: TimeInterval, totalDuration: TimeInterval, isPlaying: Bool) {
|
||||||
if isPlaying && currentTime > 0 {
|
if isPlaying && currentTime > 0 {
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
|
|
||||||
// Voice message
|
// Voice message
|
||||||
private let voiceView = MessageVoiceView()
|
private let voiceView = MessageVoiceView()
|
||||||
|
private var voiceBlobView: VoiceBlobView?
|
||||||
|
|
||||||
// Avatar-specific
|
// Avatar-specific
|
||||||
private let avatarImageView = UIImageView()
|
private let avatarImageView = UIImageView()
|
||||||
@@ -318,6 +319,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
// Raster bubble image (Telegram-exact tail) — added last so it renders above outline
|
// Raster bubble image (Telegram-exact tail) — added last so it renders above outline
|
||||||
bubbleImageView.contentMode = .scaleToFill
|
bubbleImageView.contentMode = .scaleToFill
|
||||||
bubbleView.addSubview(bubbleImageView)
|
bubbleView.addSubview(bubbleImageView)
|
||||||
|
bubbleView.clipsToBounds = true // clips voice blob animation to bubble bounds
|
||||||
contentView.addSubview(bubbleView)
|
contentView.addSubview(bubbleView)
|
||||||
|
|
||||||
// Text (CoreTextLabel — no font/color/lines config; all baked into CoreTextTextLayout)
|
// Text (CoreTextLabel — no font/color/lines config; all baked into CoreTextTextLayout)
|
||||||
@@ -1289,14 +1291,25 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
fileNameLabel.frame = CGRect(x: 63, y: textTopY, width: fileW - 75, height: 19)
|
fileNameLabel.frame = CGRect(x: 63, y: textTopY, width: fileW - 75, height: 19)
|
||||||
fileSizeLabel.frame = CGRect(x: 63, y: textTopY + 21, width: fileW - 75, height: 16)
|
fileSizeLabel.frame = CGRect(x: 63, y: textTopY + 21, width: fileW - 75, height: 16)
|
||||||
} else if !voiceView.isHidden {
|
} else if !voiceView.isHidden {
|
||||||
// Voice layout: center voiceView vertically, hide file subviews
|
// Voice layout: position in upper area with padding (Telegram: bubble insets top=15, left=9)
|
||||||
let contentH: CGFloat = 38
|
let contentH: CGFloat = 38
|
||||||
let topY = max(0, (centerableH - contentH) / 2)
|
let voiceLeftPad: CGFloat = 6 // left padding inside fileContainer
|
||||||
voiceView.frame = CGRect(x: 0, y: topY, width: fileW, height: contentH)
|
let voiceTopPad: CGFloat = 8 // top padding — positions content in upper portion
|
||||||
|
voiceView.frame = CGRect(x: voiceLeftPad, y: voiceTopPad,
|
||||||
|
width: fileW - voiceLeftPad * 2, height: contentH)
|
||||||
|
voiceView.layoutIfNeeded() // ensure playButton.frame is set before blob positioning
|
||||||
fileIconView.isHidden = true
|
fileIconView.isHidden = true
|
||||||
fileNameLabel.isHidden = true
|
fileNameLabel.isHidden = true
|
||||||
fileSizeLabel.isHidden = true
|
fileSizeLabel.isHidden = true
|
||||||
avatarImageView.isHidden = true
|
avatarImageView.isHidden = true
|
||||||
|
// Reposition blob if it exists (keeps in sync on relayout)
|
||||||
|
if let blob = voiceBlobView {
|
||||||
|
let playCenter = voiceView.convert(voiceView.playButtonCenter, to: bubbleView)
|
||||||
|
let blobSize: CGFloat = 56
|
||||||
|
blob.frame = CGRect(x: playCenter.x - blobSize / 2,
|
||||||
|
y: playCenter.y - blobSize / 2,
|
||||||
|
width: blobSize, height: blobSize)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// File layout: vertically centered icon + title + size
|
// File layout: vertically centered icon + title + size
|
||||||
let contentH: CGFloat = 44 // icon height dominates
|
let contentH: CGFloat = 44 // icon height dominates
|
||||||
@@ -2875,6 +2888,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
highlightOverlay.alpha = 0
|
highlightOverlay.alpha = 0
|
||||||
fileContainer.isHidden = true
|
fileContainer.isHidden = true
|
||||||
voiceView.isHidden = true
|
voiceView.isHidden = true
|
||||||
|
cleanupVoiceBlob()
|
||||||
callArrowView.isHidden = true
|
callArrowView.isHidden = true
|
||||||
callBackButton.isHidden = true
|
callBackButton.isHidden = true
|
||||||
groupInviteContainer.isHidden = true
|
groupInviteContainer.isHidden = true
|
||||||
@@ -2999,6 +3013,63 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
guard !voiceView.isHidden else { return }
|
guard !voiceView.isHidden else { return }
|
||||||
voiceView.updatePlaybackState(isPlaying: isPlaying, progress: progress)
|
voiceView.updatePlaybackState(isPlaying: isPlaying, progress: progress)
|
||||||
voiceView.updateDurationDuringPlayback(currentTime: currentTime, totalDuration: duration, isPlaying: isPlaying)
|
voiceView.updateDurationDuringPlayback(currentTime: currentTime, totalDuration: duration, isPlaying: isPlaying)
|
||||||
|
updateVoiceBlobState(isPlaying: isPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Voice Blob (rendered in bubbleView, clipped to bubble bounds)
|
||||||
|
|
||||||
|
private func updateVoiceBlobState(isPlaying: Bool) {
|
||||||
|
if isPlaying {
|
||||||
|
if voiceBlobView == nil {
|
||||||
|
let blob = VoiceBlobView(
|
||||||
|
frame: .zero,
|
||||||
|
maxLevel: 0.3,
|
||||||
|
smallBlobRange: (min: 0, max: 0),
|
||||||
|
mediumBlobRange: (min: 0.7, max: 0.8),
|
||||||
|
bigBlobRange: (min: 0.8, max: 0.9)
|
||||||
|
)
|
||||||
|
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||||
|
let isOut = currentLayout?.isOutgoing ?? false
|
||||||
|
let colors = RosettaColors.Voice.colors(isOutgoing: isOut, isDark: isDark)
|
||||||
|
blob.setColor(colors.playButtonBg)
|
||||||
|
|
||||||
|
// Even-odd mask to cut out the inner 44pt circle (ring only)
|
||||||
|
let blobSize: CGFloat = 56
|
||||||
|
let maskLayer = CAShapeLayer()
|
||||||
|
let fullRect = CGRect(origin: .zero, size: CGSize(width: blobSize, height: blobSize))
|
||||||
|
let path = UIBezierPath(rect: fullRect)
|
||||||
|
let innerDiameter: CGFloat = 44
|
||||||
|
let innerOrigin = CGPoint(x: (blobSize - innerDiameter) / 2,
|
||||||
|
y: (blobSize - innerDiameter) / 2)
|
||||||
|
path.append(UIBezierPath(ovalIn: CGRect(origin: innerOrigin,
|
||||||
|
size: CGSize(width: innerDiameter, height: innerDiameter))))
|
||||||
|
maskLayer.path = path.cgPath
|
||||||
|
maskLayer.fillRule = .evenOdd
|
||||||
|
blob.layer.mask = maskLayer
|
||||||
|
|
||||||
|
// Insert below fileContainer so it's behind the play button
|
||||||
|
bubbleView.insertSubview(blob, belowSubview: fileContainer)
|
||||||
|
voiceBlobView = blob
|
||||||
|
}
|
||||||
|
// Force voiceView layout so playButton.frame is up-to-date
|
||||||
|
voiceView.layoutIfNeeded()
|
||||||
|
// Position blob centered on play button in bubbleView coords
|
||||||
|
let playCenter = voiceView.convert(voiceView.playButtonCenter, to: bubbleView)
|
||||||
|
let blobSize: CGFloat = 56
|
||||||
|
voiceBlobView?.frame = CGRect(x: playCenter.x - blobSize / 2,
|
||||||
|
y: playCenter.y - blobSize / 2,
|
||||||
|
width: blobSize, height: blobSize)
|
||||||
|
voiceBlobView?.startAnimating()
|
||||||
|
voiceBlobView?.updateLevel(0.2)
|
||||||
|
} else {
|
||||||
|
voiceBlobView?.stopAnimating()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupVoiceBlob() {
|
||||||
|
voiceBlobView?.stopAnimating()
|
||||||
|
voiceBlobView?.removeFromSuperview()
|
||||||
|
voiceBlobView = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func voiceTransitionTargetFrame(in window: UIWindow) -> CGRect? {
|
func voiceTransitionTargetFrame(in window: UIWindow) -> CGRect? {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ protocol RecordingPreviewPanelDelegate: AnyObject {
|
|||||||
|
|
||||||
// MARK: - RecordingPreviewPanel
|
// MARK: - RecordingPreviewPanel
|
||||||
|
|
||||||
/// Preview panel shown after `lock -> stop`, before sending voice message.
|
/// Telegram-parity recording preview: glass delete circle + dark glass panel + blue send circle.
|
||||||
/// Includes waveform scrubbing + trim handles + send/delete/record-more controls.
|
/// Blue accent fill covers only the trim range. Play button floats inside waveform.
|
||||||
final class RecordingPreviewPanel: UIView {
|
final class RecordingPreviewPanel: UIView {
|
||||||
|
|
||||||
private enum PanMode {
|
private enum PanMode {
|
||||||
@@ -31,21 +31,39 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
|
|
||||||
weak var delegate: RecordingPreviewPanelDelegate?
|
weak var delegate: RecordingPreviewPanelDelegate?
|
||||||
|
|
||||||
// MARK: - Subviews
|
// MARK: - Background elements (3 separate visual blocks)
|
||||||
|
|
||||||
|
private let deleteGlassCircle = TelegramGlassUIView(frame: .zero)
|
||||||
|
private let centerGlassBackground = TelegramGlassUIView(frame: .zero)
|
||||||
|
private let recordMoreGlassCircle = TelegramGlassUIView(frame: .zero)
|
||||||
|
private let accentFillView = UIImageView()
|
||||||
|
|
||||||
|
// MARK: - Buttons
|
||||||
|
|
||||||
private let glassBackground = TelegramGlassUIView(frame: .zero)
|
|
||||||
private let deleteButton = UIButton(type: .system)
|
private let deleteButton = UIButton(type: .system)
|
||||||
private let playButton = UIButton(type: .system)
|
private let recordMoreButton = UIButton(type: .system)
|
||||||
private let playPauseAnimationView = LottieAnimationView()
|
private let sendButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
// MARK: - Waveform
|
||||||
|
|
||||||
private let waveformContainer = UIView()
|
private let waveformContainer = UIView()
|
||||||
private let waveformView = WaveformView()
|
private let waveformView = WaveformView()
|
||||||
private let leftTrimMask = UIView()
|
private let leftTrimMask = UIView()
|
||||||
private let rightTrimMask = UIView()
|
private let rightTrimMask = UIView()
|
||||||
private let leftTrimHandle = UIView()
|
private let leftTrimHandle = UIView()
|
||||||
private let rightTrimHandle = UIView()
|
private let rightTrimHandle = UIView()
|
||||||
|
private let leftCapsuleView = UIView()
|
||||||
|
private let rightCapsuleView = UIView()
|
||||||
|
|
||||||
|
// MARK: - Play button pill (floats inside waveform)
|
||||||
|
|
||||||
|
private let playButtonPill = UIButton(type: .custom)
|
||||||
|
private let playPillBackground = UIImageView()
|
||||||
|
private let playPauseAnimationView: LottieAnimationView = {
|
||||||
|
let config = LottieConfiguration(renderingEngine: .mainThread)
|
||||||
|
return LottieAnimationView(configuration: config)
|
||||||
|
}()
|
||||||
private let durationLabel = UILabel()
|
private let durationLabel = UILabel()
|
||||||
private let recordMoreButton = UIButton(type: .system)
|
|
||||||
private let sendButton = UIButton(type: .system)
|
|
||||||
|
|
||||||
// MARK: - Audio Playback
|
// MARK: - Audio Playback
|
||||||
|
|
||||||
@@ -64,6 +82,12 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
private var minTrimDuration: TimeInterval = 1
|
private var minTrimDuration: TimeInterval = 1
|
||||||
private var activePanMode: PanMode?
|
private var activePanMode: PanMode?
|
||||||
|
|
||||||
|
// MARK: - Layout cache
|
||||||
|
|
||||||
|
private var centerPanelFrame: CGRect = .zero
|
||||||
|
|
||||||
|
// MARK: - Colors
|
||||||
|
|
||||||
private var panelControlColor: UIColor {
|
private var panelControlColor: UIColor {
|
||||||
UIColor { traits in
|
UIColor { traits in
|
||||||
traits.userInterfaceStyle == .dark ? UIColor.white : UIColor.black
|
traits.userInterfaceStyle == .dark ? UIColor.white : UIColor.black
|
||||||
@@ -78,8 +102,12 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
panelControlColor.withAlphaComponent(0.7)
|
panelControlColor.withAlphaComponent(0.7)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var panelWaveformBackgroundColor: UIColor {
|
private var playPillBackgroundColor: UIColor {
|
||||||
panelControlColor.withAlphaComponent(0.4)
|
UIColor { traits in
|
||||||
|
traits.userInterfaceStyle == .dark
|
||||||
|
? UIColor(white: 0.12, alpha: 0.7)
|
||||||
|
: UIColor(white: 0.95, alpha: 0.7)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedTrimRange: ClosedRange<TimeInterval> {
|
var selectedTrimRange: ClosedRange<TimeInterval> {
|
||||||
@@ -94,9 +122,7 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
self.waveformSamples = waveform
|
self.waveformSamples = waveform
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
self.trimEnd = self.duration
|
self.trimEnd = self.duration
|
||||||
clipsToBounds = true
|
clipsToBounds = false
|
||||||
layer.cornerRadius = 21
|
|
||||||
layer.cornerCurve = .continuous
|
|
||||||
accessibilityIdentifier = "voice.preview.panel"
|
accessibilityIdentifier = "voice.preview.panel"
|
||||||
setupSubviews()
|
setupSubviews()
|
||||||
}
|
}
|
||||||
@@ -107,88 +133,110 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
|
|
||||||
private func setupSubviews() {
|
private func setupSubviews() {
|
||||||
glassBackground.fixedCornerRadius = 21
|
// A) Delete glass circle + button
|
||||||
glassBackground.isUserInteractionEnabled = false
|
deleteGlassCircle.fixedCornerRadius = 20
|
||||||
addSubview(glassBackground)
|
deleteGlassCircle.isUserInteractionEnabled = false
|
||||||
|
addSubview(deleteGlassCircle)
|
||||||
|
|
||||||
deleteButton.setImage(VoiceRecordingAssets.image(.delete, templated: true), for: .normal)
|
deleteButton.setImage(VoiceRecordingAssets.image(.delete, templated: true), for: .normal)
|
||||||
deleteButton.tintColor = UIColor(red: 1, green: 45/255.0, blue: 85/255.0, alpha: 1)
|
deleteButton.tintColor = panelControlColor
|
||||||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||||
deleteButton.isAccessibilityElement = true
|
|
||||||
deleteButton.accessibilityLabel = "Delete recording"
|
deleteButton.accessibilityLabel = "Delete recording"
|
||||||
deleteButton.accessibilityHint = "Deletes the current voice draft."
|
|
||||||
deleteButton.accessibilityIdentifier = "voice.preview.delete"
|
deleteButton.accessibilityIdentifier = "voice.preview.delete"
|
||||||
addSubview(deleteButton)
|
addSubview(deleteButton)
|
||||||
|
|
||||||
playPauseAnimationView.backgroundBehavior = .pauseAndRestore
|
// B) Central dark glass panel
|
||||||
playPauseAnimationView.contentMode = .scaleAspectFit
|
centerGlassBackground.isUserInteractionEnabled = false
|
||||||
playPauseAnimationView.isUserInteractionEnabled = false
|
addSubview(centerGlassBackground)
|
||||||
playButton.addSubview(playPauseAnimationView)
|
|
||||||
if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.playPause.rawValue) {
|
|
||||||
playPauseAnimationView.animation = animation
|
|
||||||
}
|
|
||||||
configurePlayButton(playing: false, animated: false)
|
|
||||||
playButton.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
|
|
||||||
playButton.isAccessibilityElement = true
|
|
||||||
playButton.accessibilityLabel = "Play recording"
|
|
||||||
playButton.accessibilityHint = "Plays or pauses voice preview."
|
|
||||||
playButton.accessibilityIdentifier = "voice.preview.playPause"
|
|
||||||
addSubview(playButton)
|
|
||||||
|
|
||||||
|
// Blue accent fill (dynamic trim range)
|
||||||
|
accentFillView.image = Self.makeStretchablePill(
|
||||||
|
diameter: 34,
|
||||||
|
color: panelControlAccentColor
|
||||||
|
)
|
||||||
|
accentFillView.isUserInteractionEnabled = false
|
||||||
|
addSubview(accentFillView)
|
||||||
|
|
||||||
|
// Waveform container
|
||||||
waveformContainer.clipsToBounds = true
|
waveformContainer.clipsToBounds = true
|
||||||
waveformContainer.layer.cornerRadius = 6
|
|
||||||
addSubview(waveformContainer)
|
addSubview(waveformContainer)
|
||||||
|
|
||||||
waveformView.setSamples(waveformSamples)
|
waveformView.setSamples(waveformSamples)
|
||||||
waveformView.progress = 0
|
waveformView.progress = 0
|
||||||
waveformContainer.addSubview(waveformView)
|
waveformContainer.addSubview(waveformView)
|
||||||
|
|
||||||
|
// Trim masks
|
||||||
leftTrimMask.backgroundColor = UIColor.black.withAlphaComponent(0.25)
|
leftTrimMask.backgroundColor = UIColor.black.withAlphaComponent(0.25)
|
||||||
rightTrimMask.backgroundColor = UIColor.black.withAlphaComponent(0.25)
|
rightTrimMask.backgroundColor = UIColor.black.withAlphaComponent(0.25)
|
||||||
waveformContainer.addSubview(leftTrimMask)
|
waveformContainer.addSubview(leftTrimMask)
|
||||||
waveformContainer.addSubview(rightTrimMask)
|
waveformContainer.addSubview(rightTrimMask)
|
||||||
|
|
||||||
leftTrimHandle.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
|
// Trim handles (transparent, 16pt wide)
|
||||||
leftTrimHandle.layer.cornerRadius = 2
|
leftTrimHandle.backgroundColor = .clear
|
||||||
waveformContainer.addSubview(leftTrimHandle)
|
rightTrimHandle.backgroundColor = .clear
|
||||||
|
addSubview(leftTrimHandle)
|
||||||
|
addSubview(rightTrimHandle)
|
||||||
|
|
||||||
rightTrimHandle.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
|
// White capsule indicators inside handles
|
||||||
rightTrimHandle.layer.cornerRadius = 2
|
leftCapsuleView.backgroundColor = .white
|
||||||
waveformContainer.addSubview(rightTrimHandle)
|
leftCapsuleView.layer.cornerRadius = 1.5
|
||||||
|
leftTrimHandle.addSubview(leftCapsuleView)
|
||||||
|
|
||||||
|
rightCapsuleView.backgroundColor = .white
|
||||||
|
rightCapsuleView.layer.cornerRadius = 1.5
|
||||||
|
rightTrimHandle.addSubview(rightCapsuleView)
|
||||||
|
|
||||||
|
// Pan gesture for waveform
|
||||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleWaveformPan(_:)))
|
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleWaveformPan(_:)))
|
||||||
waveformContainer.addGestureRecognizer(pan)
|
waveformContainer.addGestureRecognizer(pan)
|
||||||
waveformContainer.isAccessibilityElement = true
|
|
||||||
waveformContainer.accessibilityLabel = "Waveform trim area"
|
waveformContainer.accessibilityLabel = "Waveform trim area"
|
||||||
waveformContainer.accessibilityHint = "Drag to scrub, or drag edges to trim."
|
|
||||||
waveformContainer.accessibilityIdentifier = "voice.preview.waveform"
|
waveformContainer.accessibilityIdentifier = "voice.preview.waveform"
|
||||||
|
|
||||||
|
// Play button pill (inside waveform)
|
||||||
|
playPillBackground.isUserInteractionEnabled = false
|
||||||
|
playButtonPill.addSubview(playPillBackground)
|
||||||
|
|
||||||
|
playPauseAnimationView.backgroundBehavior = .pauseAndRestore
|
||||||
|
playPauseAnimationView.contentMode = .scaleAspectFit
|
||||||
|
playPauseAnimationView.isUserInteractionEnabled = false
|
||||||
|
if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.playPause.rawValue) {
|
||||||
|
playPauseAnimationView.animation = animation
|
||||||
|
}
|
||||||
|
playButtonPill.addSubview(playPauseAnimationView)
|
||||||
|
|
||||||
durationLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .semibold)
|
durationLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .semibold)
|
||||||
durationLabel.textColor = panelSecondaryTextColor
|
|
||||||
durationLabel.textAlignment = .right
|
durationLabel.textAlignment = .right
|
||||||
addSubview(durationLabel)
|
playButtonPill.addSubview(durationLabel)
|
||||||
|
|
||||||
recordMoreButton.setImage(VoiceRecordingAssets.image(.iconMicrophone, templated: true), for: .normal)
|
playButtonPill.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
|
||||||
recordMoreButton.tintColor = panelControlColor.withAlphaComponent(0.85)
|
playButtonPill.accessibilityLabel = "Play recording"
|
||||||
recordMoreButton.addTarget(self, action: #selector(recordMoreTapped), for: .touchUpInside)
|
playButtonPill.accessibilityIdentifier = "voice.preview.playPause"
|
||||||
recordMoreButton.isAccessibilityElement = true
|
waveformContainer.addSubview(playButtonPill)
|
||||||
recordMoreButton.accessibilityLabel = "Record more"
|
configurePlayButton(playing: false, animated: false)
|
||||||
recordMoreButton.accessibilityHint = "Resume recording and append more audio."
|
|
||||||
recordMoreButton.accessibilityIdentifier = "voice.preview.recordMore"
|
|
||||||
addSubview(recordMoreButton)
|
|
||||||
|
|
||||||
|
// C) Send button (solid blue circle)
|
||||||
sendButton.setImage(VoiceRecordingAssets.image(.send, templated: true), for: .normal)
|
sendButton.setImage(VoiceRecordingAssets.image(.send, templated: true), for: .normal)
|
||||||
sendButton.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
|
sendButton.backgroundColor = panelControlAccentColor
|
||||||
sendButton.layer.cornerRadius = 18
|
sendButton.layer.cornerRadius = 18
|
||||||
sendButton.clipsToBounds = true
|
sendButton.clipsToBounds = true
|
||||||
sendButton.tintColor = .white
|
sendButton.tintColor = .white
|
||||||
sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside)
|
sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside)
|
||||||
sendButton.isAccessibilityElement = true
|
|
||||||
sendButton.accessibilityLabel = "Send recording"
|
sendButton.accessibilityLabel = "Send recording"
|
||||||
sendButton.accessibilityHint = "Sends current trimmed voice message."
|
|
||||||
sendButton.accessibilityIdentifier = "voice.preview.send"
|
sendButton.accessibilityIdentifier = "voice.preview.send"
|
||||||
addSubview(sendButton)
|
addSubview(sendButton)
|
||||||
|
|
||||||
|
// D) Record More glass circle + button (floating above)
|
||||||
|
recordMoreGlassCircle.fixedCornerRadius = 20
|
||||||
|
recordMoreGlassCircle.isUserInteractionEnabled = false
|
||||||
|
addSubview(recordMoreGlassCircle)
|
||||||
|
|
||||||
|
recordMoreButton.setImage(VoiceRecordingAssets.image(.iconMicrophone, templated: true), for: .normal)
|
||||||
|
recordMoreButton.tintColor = panelControlColor
|
||||||
|
recordMoreButton.addTarget(self, action: #selector(recordMoreTapped), for: .touchUpInside)
|
||||||
|
recordMoreButton.accessibilityLabel = "Record more"
|
||||||
|
recordMoreButton.accessibilityIdentifier = "voice.preview.recordMore"
|
||||||
|
addSubview(recordMoreButton)
|
||||||
|
|
||||||
updateThemeColors()
|
updateThemeColors()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,37 +246,47 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
let h = bounds.height
|
let h = bounds.height
|
||||||
let w = bounds.width
|
let w = bounds.width
|
||||||
let trailingInset: CGFloat = 4
|
|
||||||
let controlGap: CGFloat = 4
|
|
||||||
|
|
||||||
glassBackground.frame = bounds
|
// Delete — glass circle, left edge
|
||||||
glassBackground.applyCornerRadius()
|
let deleteSize: CGFloat = 40
|
||||||
|
let deleteFrame = CGRect(x: 0, y: (h - deleteSize) / 2, width: deleteSize, height: deleteSize)
|
||||||
|
deleteGlassCircle.frame = deleteFrame
|
||||||
|
deleteGlassCircle.applyCornerRadius()
|
||||||
|
deleteButton.frame = deleteFrame
|
||||||
|
|
||||||
deleteButton.frame = CGRect(x: 4, y: (h - 40) / 2, width: 40, height: 40)
|
// Send — solid blue circle, right edge
|
||||||
playButton.frame = CGRect(x: 44, y: (h - 30) / 2, width: 30, height: 30)
|
let sendSize: CGFloat = 36
|
||||||
playPauseAnimationView.frame = CGRect(x: 4, y: 4, width: 22, height: 22)
|
sendButton.frame = CGRect(x: w - sendSize, y: (h - sendSize) / 2, width: sendSize, height: sendSize)
|
||||||
|
|
||||||
sendButton.frame = CGRect(x: w - trailingInset - 36, y: (h - 36) / 2, width: 36, height: 36)
|
// Central dark glass panel — between delete and send
|
||||||
recordMoreButton.frame = CGRect(
|
let panelGap: CGFloat = 6
|
||||||
x: sendButton.frame.minX - controlGap - 30,
|
let panelX = deleteSize + panelGap
|
||||||
y: (h - 30) / 2,
|
let panelW = w - panelX - sendSize - panelGap
|
||||||
width: 30,
|
let panelH = h - 6
|
||||||
height: 30
|
let panelY: CGFloat = 3
|
||||||
)
|
let panelCornerRadius = panelH / 2
|
||||||
|
centerGlassBackground.frame = CGRect(x: panelX, y: panelY, width: panelW, height: panelH)
|
||||||
|
centerGlassBackground.fixedCornerRadius = panelCornerRadius
|
||||||
|
centerGlassBackground.applyCornerRadius()
|
||||||
|
centerPanelFrame = CGRect(x: panelX, y: panelY, width: panelW, height: panelH)
|
||||||
|
|
||||||
let durationW: CGFloat = 48
|
// Waveform — inside central panel, 18pt insets from panel edges
|
||||||
durationLabel.frame = CGRect(
|
let wfInset: CGFloat = 18
|
||||||
x: recordMoreButton.frame.minX - durationW - 6,
|
let wfX = panelX + wfInset
|
||||||
y: (h - 20) / 2,
|
let wfW = panelW - wfInset * 2
|
||||||
width: durationW,
|
let wfH: CGFloat = 13
|
||||||
height: 20
|
let wfY = floor((h - wfH) / 2)
|
||||||
)
|
waveformContainer.frame = CGRect(x: wfX, y: wfY, width: max(0, wfW), height: wfH)
|
||||||
|
|
||||||
let waveX = playButton.frame.maxX + 8
|
|
||||||
let waveW = durationLabel.frame.minX - 8 - waveX
|
|
||||||
waveformContainer.frame = CGRect(x: waveX, y: 4, width: max(0, waveW), height: h - 8)
|
|
||||||
waveformView.frame = waveformContainer.bounds
|
waveformView.frame = waveformContainer.bounds
|
||||||
|
|
||||||
|
// RecordMore — floating above, right side
|
||||||
|
let rmSize: CGFloat = 40
|
||||||
|
let rmFrame = CGRect(x: w - rmSize - 10, y: -52, width: rmSize, height: rmSize)
|
||||||
|
recordMoreGlassCircle.frame = rmFrame
|
||||||
|
recordMoreGlassCircle.applyCornerRadius()
|
||||||
|
recordMoreButton.frame = rmFrame
|
||||||
|
|
||||||
|
// Trim computation
|
||||||
minTrimDuration = VoiceRecordingParityConstants.minTrimDuration(
|
minTrimDuration = VoiceRecordingParityConstants.minTrimDuration(
|
||||||
duration: duration,
|
duration: duration,
|
||||||
waveformWidth: waveformContainer.bounds.width
|
waveformWidth: waveformContainer.bounds.width
|
||||||
@@ -297,7 +355,6 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
playPauseState = targetState
|
playPauseState = targetState
|
||||||
|
|
||||||
if playPauseAnimationView.animation != nil {
|
if playPauseAnimationView.animation != nil {
|
||||||
playButton.setImage(nil, for: .normal)
|
|
||||||
switch (previous, targetState) {
|
switch (previous, targetState) {
|
||||||
case (.play, .pause):
|
case (.play, .pause):
|
||||||
if animated {
|
if animated {
|
||||||
@@ -316,13 +373,7 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
case (.pause, .pause):
|
case (.pause, .pause):
|
||||||
playPauseAnimationView.currentFrame = 41
|
playPauseAnimationView.currentFrame = 41
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = UIImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
|
|
||||||
let fallbackName = playing ? "pause.fill" : "play.fill"
|
|
||||||
playButton.setImage(UIImage(systemName: fallbackName, withConfiguration: config), for: .normal)
|
|
||||||
playButton.tintColor = panelSecondaryTextColor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Display Link
|
// MARK: - Display Link
|
||||||
@@ -367,9 +418,9 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
case .began:
|
case .began:
|
||||||
let leftX = xForTime(trimStart)
|
let leftX = xForTime(trimStart)
|
||||||
let rightX = xForTime(trimEnd)
|
let rightX = xForTime(trimEnd)
|
||||||
if abs(location.x - leftX) <= 14 {
|
if abs(location.x - leftX) <= 17 {
|
||||||
activePanMode = .trimLeft
|
activePanMode = .trimLeft
|
||||||
} else if abs(location.x - rightX) <= 14 {
|
} else if abs(location.x - rightX) <= 17 {
|
||||||
activePanMode = .trimRight
|
activePanMode = .trimRight
|
||||||
} else {
|
} else {
|
||||||
activePanMode = .scrub
|
activePanMode = .scrub
|
||||||
@@ -377,6 +428,12 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
if activePanMode != .scrub {
|
if activePanMode != .scrub {
|
||||||
pausePlayback()
|
pausePlayback()
|
||||||
}
|
}
|
||||||
|
// Hide play pill during scrub
|
||||||
|
if activePanMode == .scrub {
|
||||||
|
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
|
||||||
|
self.playButtonPill.alpha = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
case .changed:
|
case .changed:
|
||||||
switch activePanMode {
|
switch activePanMode {
|
||||||
case .trimLeft:
|
case .trimLeft:
|
||||||
@@ -408,22 +465,98 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
activePanMode = nil
|
activePanMode = nil
|
||||||
|
// Show play pill again
|
||||||
|
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
|
||||||
|
self.playButtonPill.alpha = 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateTrimVisuals() {
|
private func updateTrimVisuals() {
|
||||||
let h = waveformContainer.bounds.height
|
let wfW = waveformContainer.bounds.width
|
||||||
let w = waveformContainer.bounds.width
|
let wfH = waveformContainer.bounds.height
|
||||||
guard w > 0 else { return }
|
guard wfW > 0 else { return }
|
||||||
|
|
||||||
let startX = xForTime(trimStart)
|
let startX = xForTime(trimStart)
|
||||||
let endX = xForTime(trimEnd)
|
let endX = xForTime(trimEnd)
|
||||||
leftTrimMask.frame = CGRect(x: 0, y: 0, width: max(0, startX), height: h)
|
|
||||||
rightTrimMask.frame = CGRect(x: min(w, endX), y: 0, width: max(0, w - endX), height: h)
|
|
||||||
|
|
||||||
let handleW: CGFloat = 4
|
// Trim masks (dim areas outside trim range)
|
||||||
leftTrimHandle.frame = CGRect(x: max(0, startX - handleW / 2), y: 0, width: handleW, height: h)
|
leftTrimMask.frame = CGRect(x: 0, y: 0, width: max(0, startX), height: wfH)
|
||||||
rightTrimHandle.frame = CGRect(x: min(w - handleW, endX - handleW / 2), y: 0, width: handleW, height: h)
|
rightTrimMask.frame = CGRect(x: min(wfW, endX), y: 0, width: max(0, wfW - endX), height: wfH)
|
||||||
|
|
||||||
|
// Trim handles (16pt wide, positioned at panel level)
|
||||||
|
let handleW: CGFloat = 16
|
||||||
|
let handleH = centerPanelFrame.height
|
||||||
|
let handleY = centerPanelFrame.minY
|
||||||
|
let wfOriginX = waveformContainer.frame.minX
|
||||||
|
|
||||||
|
leftTrimHandle.frame = CGRect(
|
||||||
|
x: wfOriginX + startX - handleW,
|
||||||
|
y: handleY,
|
||||||
|
width: handleW,
|
||||||
|
height: handleH
|
||||||
|
)
|
||||||
|
rightTrimHandle.frame = CGRect(
|
||||||
|
x: wfOriginX + endX,
|
||||||
|
y: handleY,
|
||||||
|
width: handleW,
|
||||||
|
height: handleH
|
||||||
|
)
|
||||||
|
|
||||||
|
// Capsule indicators (3×12, centered vertically, 8pt from outer edge)
|
||||||
|
let capsuleW: CGFloat = 3
|
||||||
|
let capsuleH: CGFloat = 12
|
||||||
|
let capsuleY = (handleH - capsuleH) / 2
|
||||||
|
// Left capsule: 8pt from right edge of left handle (outer edge faces left, capsule near waveform)
|
||||||
|
leftCapsuleView.frame = CGRect(x: handleW - 8 - capsuleW, y: capsuleY, width: capsuleW, height: capsuleH)
|
||||||
|
// Right capsule: 8pt from left edge of right handle
|
||||||
|
rightCapsuleView.frame = CGRect(x: 8, y: capsuleY, width: capsuleW, height: capsuleH)
|
||||||
|
|
||||||
|
// Hide trim handles when duration < 2s
|
||||||
|
let showTrim = duration >= 2.0
|
||||||
|
leftTrimHandle.isHidden = !showTrim
|
||||||
|
rightTrimHandle.isHidden = !showTrim
|
||||||
|
|
||||||
|
// Blue accent fill — dynamic between trim handles (covers panel area)
|
||||||
|
let fillX: CGFloat
|
||||||
|
let fillW: CGFloat
|
||||||
|
if showTrim {
|
||||||
|
fillX = wfOriginX + startX - 18 // extend to panel-edge inset
|
||||||
|
let fillEndX = wfOriginX + endX + 18
|
||||||
|
let clampedX = max(centerPanelFrame.minX, fillX)
|
||||||
|
let clampedEndX = min(centerPanelFrame.maxX, fillEndX)
|
||||||
|
fillW = clampedEndX - clampedX
|
||||||
|
accentFillView.frame = CGRect(x: clampedX, y: centerPanelFrame.minY, width: max(0, fillW), height: centerPanelFrame.height)
|
||||||
|
} else {
|
||||||
|
// No trim — fill entire central panel
|
||||||
|
accentFillView.frame = centerPanelFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play button pill — centered between trim handles
|
||||||
|
let space = endX - startX
|
||||||
|
let pillW: CGFloat = space >= 70 ? 63 : 27
|
||||||
|
let pillH: CGFloat = 22
|
||||||
|
let pillX = startX + (space - pillW) / 2
|
||||||
|
let pillY = (wfH - pillH) / 2
|
||||||
|
playButtonPill.frame = CGRect(x: pillX, y: pillY, width: pillW, height: pillH)
|
||||||
|
|
||||||
|
// Pill background
|
||||||
|
let pillCornerRadius = pillH / 2
|
||||||
|
if playPillBackground.image == nil || playPillBackground.frame.size != playButtonPill.bounds.size {
|
||||||
|
playPillBackground.image = Self.makeStretchablePill(diameter: pillH, color: .white)?.withRenderingMode(.alwaysTemplate)
|
||||||
|
playPillBackground.tintColor = playPillBackgroundColor
|
||||||
|
}
|
||||||
|
playPillBackground.frame = playButtonPill.bounds
|
||||||
|
playPillBackground.layer.cornerRadius = pillCornerRadius
|
||||||
|
playPillBackground.clipsToBounds = true
|
||||||
|
|
||||||
|
// Lottie icon inside pill
|
||||||
|
playPauseAnimationView.frame = CGRect(x: 3, y: 1, width: 21, height: 21)
|
||||||
|
|
||||||
|
// Duration label inside pill
|
||||||
|
let showDuration = pillW > 27
|
||||||
|
durationLabel.isHidden = !showDuration
|
||||||
|
durationLabel.frame = CGRect(x: 18, y: 3, width: 35, height: 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func xForTime(_ time: TimeInterval) -> CGFloat {
|
private func xForTime(_ time: TimeInterval) -> CGFloat {
|
||||||
@@ -447,15 +580,29 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateThemeColors() {
|
private func updateThemeColors() {
|
||||||
durationLabel.textColor = panelSecondaryTextColor
|
// Waveform colors: white bars on blue accent background
|
||||||
recordMoreButton.tintColor = panelControlColor.withAlphaComponent(0.85)
|
waveformView.backgroundColor_ = UIColor.white.withAlphaComponent(0.4)
|
||||||
waveformView.backgroundColor_ = panelWaveformBackgroundColor
|
waveformView.foregroundColor_ = UIColor.white
|
||||||
waveformView.foregroundColor_ = panelControlAccentColor
|
|
||||||
waveformView.setNeedsDisplay()
|
waveformView.setNeedsDisplay()
|
||||||
|
|
||||||
|
// Delete button tint
|
||||||
|
deleteButton.tintColor = panelControlColor
|
||||||
|
|
||||||
|
// Record more tint
|
||||||
|
recordMoreButton.tintColor = panelControlColor
|
||||||
|
|
||||||
|
// Play pill text + icon
|
||||||
|
durationLabel.textColor = panelSecondaryTextColor
|
||||||
applyPlayPauseTintColor(panelSecondaryTextColor)
|
applyPlayPauseTintColor(panelSecondaryTextColor)
|
||||||
if playPauseAnimationView.animation == nil {
|
|
||||||
playButton.tintColor = panelSecondaryTextColor
|
// Pill background
|
||||||
}
|
playPillBackground.tintColor = playPillBackgroundColor
|
||||||
|
|
||||||
|
// Accent fill
|
||||||
|
accentFillView.image = Self.makeStretchablePill(
|
||||||
|
diameter: 34,
|
||||||
|
color: panelControlAccentColor
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyPlayPauseTintColor(_ color: UIColor) {
|
private func applyPlayPauseTintColor(_ color: UIColor) {
|
||||||
@@ -511,6 +658,21 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private static func makeStretchablePill(diameter: CGFloat, color: UIColor) -> UIImage? {
|
||||||
|
let size = CGSize(width: diameter, height: diameter)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
|
let image = renderer.image { _ in
|
||||||
|
color.setFill()
|
||||||
|
UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill()
|
||||||
|
}
|
||||||
|
let half = diameter / 2
|
||||||
|
return image.resizableImage(withCapInsets: UIEdgeInsets(
|
||||||
|
top: half, left: half, bottom: half, right: half
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
stopDisplayLink()
|
stopDisplayLink()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user