Исправлен рендеринг voice blob — убран shapeLayer.bounds, уменьшен размер до 56pt
This commit is contained in:
@@ -246,7 +246,11 @@ private final class BlobLayer {
|
||||
|
||||
func updateBounds(_ size: CGSize) {
|
||||
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)
|
||||
|
||||
if isCircle {
|
||||
|
||||
@@ -1278,12 +1278,14 @@ extension ComposerView: RecordingMicButtonDelegate {
|
||||
|
||||
if enabled {
|
||||
inputContainer.alpha = 1
|
||||
inputContainer.clipsToBounds = false // RecordMore floats above panel
|
||||
attachButton.alpha = 0
|
||||
micButton.alpha = 0
|
||||
micGlass.alpha = 0
|
||||
micIconView?.alpha = 0
|
||||
bringSubviewToFront(inputContainer)
|
||||
} else {
|
||||
inputContainer.clipsToBounds = true
|
||||
replyBar.alpha = isReplyVisible ? 1 : 0
|
||||
bringSubviewToFront(micButton)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ final class MessageVoiceView: UIView {
|
||||
private var isOutgoing = false
|
||||
private var isShowingPause = false // tracks Lottie visual state
|
||||
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)
|
||||
|
||||
@@ -136,11 +137,6 @@ final class MessageVoiceView: UIView {
|
||||
playButton.setImage(nil, for: .normal)
|
||||
}
|
||||
|
||||
// Remove blob from previous cell reuse
|
||||
blobView?.stopAnimating()
|
||||
blobView?.removeFromSuperview()
|
||||
blobView = nil
|
||||
|
||||
self.totalDuration = duration
|
||||
|
||||
// Decode waveform from preview
|
||||
@@ -202,9 +198,6 @@ final class MessageVoiceView: UIView {
|
||||
}
|
||||
waveformView.enableScrubbing = isPlaying
|
||||
|
||||
// Blob animation (Telegram: VoiceBlobNode around play button during playback)
|
||||
updateBlobState(isPlaying: isPlaying)
|
||||
|
||||
let shouldShowPause = isPlaying
|
||||
guard shouldShowPause != isShowingPause else { return }
|
||||
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.
|
||||
func updateDurationDuringPlayback(currentTime: TimeInterval, totalDuration: TimeInterval, isPlaying: Bool) {
|
||||
if isPlaying && currentTime > 0 {
|
||||
|
||||
@@ -192,6 +192,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
|
||||
// Voice message
|
||||
private let voiceView = MessageVoiceView()
|
||||
private var voiceBlobView: VoiceBlobView?
|
||||
|
||||
// Avatar-specific
|
||||
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
|
||||
bubbleImageView.contentMode = .scaleToFill
|
||||
bubbleView.addSubview(bubbleImageView)
|
||||
bubbleView.clipsToBounds = true // clips voice blob animation to bubble bounds
|
||||
contentView.addSubview(bubbleView)
|
||||
|
||||
// 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)
|
||||
fileSizeLabel.frame = CGRect(x: 63, y: textTopY + 21, width: fileW - 75, height: 16)
|
||||
} 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 topY = max(0, (centerableH - contentH) / 2)
|
||||
voiceView.frame = CGRect(x: 0, y: topY, width: fileW, height: contentH)
|
||||
let voiceLeftPad: CGFloat = 6 // left padding inside fileContainer
|
||||
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
|
||||
fileNameLabel.isHidden = true
|
||||
fileSizeLabel.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 {
|
||||
// File layout: vertically centered icon + title + size
|
||||
let contentH: CGFloat = 44 // icon height dominates
|
||||
@@ -2875,6 +2888,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
highlightOverlay.alpha = 0
|
||||
fileContainer.isHidden = true
|
||||
voiceView.isHidden = true
|
||||
cleanupVoiceBlob()
|
||||
callArrowView.isHidden = true
|
||||
callBackButton.isHidden = true
|
||||
groupInviteContainer.isHidden = true
|
||||
@@ -2999,6 +3013,63 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
guard !voiceView.isHidden else { return }
|
||||
voiceView.updatePlaybackState(isPlaying: isPlaying, progress: progress)
|
||||
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? {
|
||||
|
||||
@@ -14,8 +14,8 @@ protocol RecordingPreviewPanelDelegate: AnyObject {
|
||||
|
||||
// MARK: - RecordingPreviewPanel
|
||||
|
||||
/// Preview panel shown after `lock -> stop`, before sending voice message.
|
||||
/// Includes waveform scrubbing + trim handles + send/delete/record-more controls.
|
||||
/// Telegram-parity recording preview: glass delete circle + dark glass panel + blue send circle.
|
||||
/// Blue accent fill covers only the trim range. Play button floats inside waveform.
|
||||
final class RecordingPreviewPanel: UIView {
|
||||
|
||||
private enum PanMode {
|
||||
@@ -31,21 +31,39 @@ final class RecordingPreviewPanel: UIView {
|
||||
|
||||
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 playButton = UIButton(type: .system)
|
||||
private let playPauseAnimationView = LottieAnimationView()
|
||||
private let recordMoreButton = UIButton(type: .system)
|
||||
private let sendButton = UIButton(type: .system)
|
||||
|
||||
// MARK: - Waveform
|
||||
|
||||
private let waveformContainer = UIView()
|
||||
private let waveformView = WaveformView()
|
||||
private let leftTrimMask = UIView()
|
||||
private let rightTrimMask = UIView()
|
||||
private let leftTrimHandle = 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 recordMoreButton = UIButton(type: .system)
|
||||
private let sendButton = UIButton(type: .system)
|
||||
|
||||
// MARK: - Audio Playback
|
||||
|
||||
@@ -64,6 +82,12 @@ final class RecordingPreviewPanel: UIView {
|
||||
private var minTrimDuration: TimeInterval = 1
|
||||
private var activePanMode: PanMode?
|
||||
|
||||
// MARK: - Layout cache
|
||||
|
||||
private var centerPanelFrame: CGRect = .zero
|
||||
|
||||
// MARK: - Colors
|
||||
|
||||
private var panelControlColor: UIColor {
|
||||
UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark ? UIColor.white : UIColor.black
|
||||
@@ -78,8 +102,12 @@ final class RecordingPreviewPanel: UIView {
|
||||
panelControlColor.withAlphaComponent(0.7)
|
||||
}
|
||||
|
||||
private var panelWaveformBackgroundColor: UIColor {
|
||||
panelControlColor.withAlphaComponent(0.4)
|
||||
private var playPillBackgroundColor: UIColor {
|
||||
UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(white: 0.12, alpha: 0.7)
|
||||
: UIColor(white: 0.95, alpha: 0.7)
|
||||
}
|
||||
}
|
||||
|
||||
var selectedTrimRange: ClosedRange<TimeInterval> {
|
||||
@@ -94,9 +122,7 @@ final class RecordingPreviewPanel: UIView {
|
||||
self.waveformSamples = waveform
|
||||
super.init(frame: frame)
|
||||
self.trimEnd = self.duration
|
||||
clipsToBounds = true
|
||||
layer.cornerRadius = 21
|
||||
layer.cornerCurve = .continuous
|
||||
clipsToBounds = false
|
||||
accessibilityIdentifier = "voice.preview.panel"
|
||||
setupSubviews()
|
||||
}
|
||||
@@ -107,88 +133,110 @@ final class RecordingPreviewPanel: UIView {
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupSubviews() {
|
||||
glassBackground.fixedCornerRadius = 21
|
||||
glassBackground.isUserInteractionEnabled = false
|
||||
addSubview(glassBackground)
|
||||
// A) Delete glass circle + button
|
||||
deleteGlassCircle.fixedCornerRadius = 20
|
||||
deleteGlassCircle.isUserInteractionEnabled = false
|
||||
addSubview(deleteGlassCircle)
|
||||
|
||||
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.isAccessibilityElement = true
|
||||
deleteButton.accessibilityLabel = "Delete recording"
|
||||
deleteButton.accessibilityHint = "Deletes the current voice draft."
|
||||
deleteButton.accessibilityIdentifier = "voice.preview.delete"
|
||||
addSubview(deleteButton)
|
||||
|
||||
playPauseAnimationView.backgroundBehavior = .pauseAndRestore
|
||||
playPauseAnimationView.contentMode = .scaleAspectFit
|
||||
playPauseAnimationView.isUserInteractionEnabled = false
|
||||
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)
|
||||
// B) Central dark glass panel
|
||||
centerGlassBackground.isUserInteractionEnabled = false
|
||||
addSubview(centerGlassBackground)
|
||||
|
||||
// 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.layer.cornerRadius = 6
|
||||
addSubview(waveformContainer)
|
||||
|
||||
waveformView.setSamples(waveformSamples)
|
||||
waveformView.progress = 0
|
||||
waveformContainer.addSubview(waveformView)
|
||||
|
||||
// Trim masks
|
||||
leftTrimMask.backgroundColor = UIColor.black.withAlphaComponent(0.25)
|
||||
rightTrimMask.backgroundColor = UIColor.black.withAlphaComponent(0.25)
|
||||
waveformContainer.addSubview(leftTrimMask)
|
||||
waveformContainer.addSubview(rightTrimMask)
|
||||
|
||||
leftTrimHandle.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
|
||||
leftTrimHandle.layer.cornerRadius = 2
|
||||
waveformContainer.addSubview(leftTrimHandle)
|
||||
// Trim handles (transparent, 16pt wide)
|
||||
leftTrimHandle.backgroundColor = .clear
|
||||
rightTrimHandle.backgroundColor = .clear
|
||||
addSubview(leftTrimHandle)
|
||||
addSubview(rightTrimHandle)
|
||||
|
||||
rightTrimHandle.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
|
||||
rightTrimHandle.layer.cornerRadius = 2
|
||||
waveformContainer.addSubview(rightTrimHandle)
|
||||
// White capsule indicators inside handles
|
||||
leftCapsuleView.backgroundColor = .white
|
||||
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(_:)))
|
||||
waveformContainer.addGestureRecognizer(pan)
|
||||
waveformContainer.isAccessibilityElement = true
|
||||
waveformContainer.accessibilityLabel = "Waveform trim area"
|
||||
waveformContainer.accessibilityHint = "Drag to scrub, or drag edges to trim."
|
||||
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.textColor = panelSecondaryTextColor
|
||||
durationLabel.textAlignment = .right
|
||||
addSubview(durationLabel)
|
||||
playButtonPill.addSubview(durationLabel)
|
||||
|
||||
recordMoreButton.setImage(VoiceRecordingAssets.image(.iconMicrophone, templated: true), for: .normal)
|
||||
recordMoreButton.tintColor = panelControlColor.withAlphaComponent(0.85)
|
||||
recordMoreButton.addTarget(self, action: #selector(recordMoreTapped), for: .touchUpInside)
|
||||
recordMoreButton.isAccessibilityElement = true
|
||||
recordMoreButton.accessibilityLabel = "Record more"
|
||||
recordMoreButton.accessibilityHint = "Resume recording and append more audio."
|
||||
recordMoreButton.accessibilityIdentifier = "voice.preview.recordMore"
|
||||
addSubview(recordMoreButton)
|
||||
playButtonPill.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
|
||||
playButtonPill.accessibilityLabel = "Play recording"
|
||||
playButtonPill.accessibilityIdentifier = "voice.preview.playPause"
|
||||
waveformContainer.addSubview(playButtonPill)
|
||||
configurePlayButton(playing: false, animated: false)
|
||||
|
||||
// C) Send button (solid blue circle)
|
||||
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.clipsToBounds = true
|
||||
sendButton.tintColor = .white
|
||||
sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside)
|
||||
sendButton.isAccessibilityElement = true
|
||||
sendButton.accessibilityLabel = "Send recording"
|
||||
sendButton.accessibilityHint = "Sends current trimmed voice message."
|
||||
sendButton.accessibilityIdentifier = "voice.preview.send"
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -198,37 +246,47 @@ final class RecordingPreviewPanel: UIView {
|
||||
super.layoutSubviews()
|
||||
let h = bounds.height
|
||||
let w = bounds.width
|
||||
let trailingInset: CGFloat = 4
|
||||
let controlGap: CGFloat = 4
|
||||
|
||||
glassBackground.frame = bounds
|
||||
glassBackground.applyCornerRadius()
|
||||
// Delete — glass circle, left edge
|
||||
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)
|
||||
playButton.frame = CGRect(x: 44, y: (h - 30) / 2, width: 30, height: 30)
|
||||
playPauseAnimationView.frame = CGRect(x: 4, y: 4, width: 22, height: 22)
|
||||
// Send — solid blue circle, right edge
|
||||
let sendSize: CGFloat = 36
|
||||
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)
|
||||
recordMoreButton.frame = CGRect(
|
||||
x: sendButton.frame.minX - controlGap - 30,
|
||||
y: (h - 30) / 2,
|
||||
width: 30,
|
||||
height: 30
|
||||
)
|
||||
// Central dark glass panel — between delete and send
|
||||
let panelGap: CGFloat = 6
|
||||
let panelX = deleteSize + panelGap
|
||||
let panelW = w - panelX - sendSize - panelGap
|
||||
let panelH = h - 6
|
||||
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
|
||||
durationLabel.frame = CGRect(
|
||||
x: recordMoreButton.frame.minX - durationW - 6,
|
||||
y: (h - 20) / 2,
|
||||
width: durationW,
|
||||
height: 20
|
||||
)
|
||||
|
||||
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)
|
||||
// Waveform — inside central panel, 18pt insets from panel edges
|
||||
let wfInset: CGFloat = 18
|
||||
let wfX = panelX + wfInset
|
||||
let wfW = panelW - wfInset * 2
|
||||
let wfH: CGFloat = 13
|
||||
let wfY = floor((h - wfH) / 2)
|
||||
waveformContainer.frame = CGRect(x: wfX, y: wfY, width: max(0, wfW), height: wfH)
|
||||
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(
|
||||
duration: duration,
|
||||
waveformWidth: waveformContainer.bounds.width
|
||||
@@ -297,7 +355,6 @@ final class RecordingPreviewPanel: UIView {
|
||||
playPauseState = targetState
|
||||
|
||||
if playPauseAnimationView.animation != nil {
|
||||
playButton.setImage(nil, for: .normal)
|
||||
switch (previous, targetState) {
|
||||
case (.play, .pause):
|
||||
if animated {
|
||||
@@ -316,13 +373,7 @@ final class RecordingPreviewPanel: UIView {
|
||||
case (.pause, .pause):
|
||||
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
|
||||
@@ -367,9 +418,9 @@ final class RecordingPreviewPanel: UIView {
|
||||
case .began:
|
||||
let leftX = xForTime(trimStart)
|
||||
let rightX = xForTime(trimEnd)
|
||||
if abs(location.x - leftX) <= 14 {
|
||||
if abs(location.x - leftX) <= 17 {
|
||||
activePanMode = .trimLeft
|
||||
} else if abs(location.x - rightX) <= 14 {
|
||||
} else if abs(location.x - rightX) <= 17 {
|
||||
activePanMode = .trimRight
|
||||
} else {
|
||||
activePanMode = .scrub
|
||||
@@ -377,6 +428,12 @@ final class RecordingPreviewPanel: UIView {
|
||||
if activePanMode != .scrub {
|
||||
pausePlayback()
|
||||
}
|
||||
// Hide play pill during scrub
|
||||
if activePanMode == .scrub {
|
||||
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
|
||||
self.playButtonPill.alpha = 0
|
||||
}
|
||||
}
|
||||
case .changed:
|
||||
switch activePanMode {
|
||||
case .trimLeft:
|
||||
@@ -408,22 +465,98 @@ final class RecordingPreviewPanel: UIView {
|
||||
}
|
||||
default:
|
||||
activePanMode = nil
|
||||
// Show play pill again
|
||||
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
|
||||
self.playButtonPill.alpha = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateTrimVisuals() {
|
||||
let h = waveformContainer.bounds.height
|
||||
let w = waveformContainer.bounds.width
|
||||
guard w > 0 else { return }
|
||||
let wfW = waveformContainer.bounds.width
|
||||
let wfH = waveformContainer.bounds.height
|
||||
guard wfW > 0 else { return }
|
||||
|
||||
let startX = xForTime(trimStart)
|
||||
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
|
||||
leftTrimHandle.frame = CGRect(x: max(0, startX - handleW / 2), y: 0, width: handleW, height: h)
|
||||
rightTrimHandle.frame = CGRect(x: min(w - handleW, endX - handleW / 2), y: 0, width: handleW, height: h)
|
||||
// Trim masks (dim areas outside trim range)
|
||||
leftTrimMask.frame = CGRect(x: 0, y: 0, width: max(0, startX), height: wfH)
|
||||
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 {
|
||||
@@ -447,15 +580,29 @@ final class RecordingPreviewPanel: UIView {
|
||||
}
|
||||
|
||||
private func updateThemeColors() {
|
||||
durationLabel.textColor = panelSecondaryTextColor
|
||||
recordMoreButton.tintColor = panelControlColor.withAlphaComponent(0.85)
|
||||
waveformView.backgroundColor_ = panelWaveformBackgroundColor
|
||||
waveformView.foregroundColor_ = panelControlAccentColor
|
||||
// Waveform colors: white bars on blue accent background
|
||||
waveformView.backgroundColor_ = UIColor.white.withAlphaComponent(0.4)
|
||||
waveformView.foregroundColor_ = UIColor.white
|
||||
waveformView.setNeedsDisplay()
|
||||
|
||||
// Delete button tint
|
||||
deleteButton.tintColor = panelControlColor
|
||||
|
||||
// Record more tint
|
||||
recordMoreButton.tintColor = panelControlColor
|
||||
|
||||
// Play pill text + icon
|
||||
durationLabel.textColor = 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) {
|
||||
@@ -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 {
|
||||
stopDisplayLink()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user