Files
mobile-ios/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift
senseiGai e905301080 Фикс: стабильная виброотдача при записи голосового сообщения
- Хаптик срабатывает ДО запуска AVAudioSession (иначе глушится)
- Убрана двойная вибрация (AudioServicesPlaySystemSound + impactOccurred)
- Прогрев Taptic Engine в didMoveToWindow + beginTracking
- Recording chrome показывается синхронно до async Task
2026-04-18 11:42:11 +05:00

360 lines
12 KiB
Swift

import QuartzCore
import UIKit
// MARK: - Recording State
enum VoiceRecordingState {
case idle
case waiting // finger down, waiting for threshold (0.19s)
case recording // actively recording, finger held
case locked // slid up past lock threshold, finger released
case cancelled // slid left past cancel threshold
case finished // finger released normally send
}
// MARK: - RecordingMicButtonDelegate
@MainActor
protocol RecordingMicButtonDelegate: AnyObject {
/// Finger down, hold timer armed.
func micButtonRecordingArmed(_ button: RecordingMicButton)
/// Hold was cancelled before threshold (tap / move / system cancel).
func micButtonRecordingArmingCancelled(_ button: RecordingMicButton)
/// Recording threshold reached (0.19s hold). Start actual recording.
func micButtonRecordingBegan(_ button: RecordingMicButton)
/// Finger released normally send the recording.
func micButtonRecordingFinished(_ button: RecordingMicButton)
/// Slid left past cancel threshold discard recording.
func micButtonRecordingCancelled(_ button: RecordingMicButton)
/// Slid up past lock threshold lock into hands-free recording.
func micButtonRecordingLocked(_ button: RecordingMicButton)
/// Raw drag distances for overlay transforms (Telegram: continueTrackingWithTouch).
/// distanceX: negative = left (cancel), distanceY: negative = up (lock)
func micButtonDragUpdate(_ button: RecordingMicButton, distanceX: CGFloat, distanceY: CGFloat)
}
// MARK: - RecordingMicButton
/// Custom UIControl that handles voice recording gestures.
/// Ported from Telegram's `TGModernConversationInputMicButton`.
///
/// Gesture mechanics:
/// - Long press (0.19s) begin recording
/// - Slide left cancel (threshold: -150px, haptic at -100px)
/// - Slide up lock (threshold: -110px, haptic at -60px)
/// - Release velocity gate: <-400 px/s on X/Y commits cancel/lock
/// - Release finish (send)
final class RecordingMicButton: UIControl {
weak var recordingDelegate: RecordingMicButtonDelegate?
private(set) var recordingState: VoiceRecordingState = .idle
// MARK: - Gesture Thresholds (Telegram parity)
private let holdThreshold: TimeInterval = VoiceRecordingParityConstants.holdThreshold
private let cancelDistanceThreshold: CGFloat = VoiceRecordingParityConstants.cancelDistanceThreshold
private let cancelHapticThreshold: CGFloat = VoiceRecordingParityConstants.cancelHapticThreshold
private let lockDistanceThreshold: CGFloat = VoiceRecordingParityConstants.lockDistanceThreshold
private let lockHapticThreshold: CGFloat = VoiceRecordingParityConstants.lockHapticThreshold
private let velocityGate: CGFloat = VoiceRecordingParityConstants.velocityGate
private let preHoldCancelDistance: CGFloat = VoiceRecordingParityConstants.preHoldCancelDistance
// MARK: - Tracking State
private var touchStartLocation: CGPoint = .zero
private var lastTouchLocation: CGPoint = .zero
private var lastTouchTimestamp: TimeInterval = 0
private var velocityX: CGFloat = 0
private var velocityY: CGFloat = 0
private var holdTimer: Timer?
private var displayLink: CADisplayLink?
// Raw target values (set by touch events)
private var targetCancelTranslation: CGFloat = 0
private var targetLockTranslation: CGFloat = 0
// Smoothed values (updated by display link)
private var currentCancelTranslation: CGFloat = 0
private var currentLockTranslation: CGFloat = 0
// Haptic tracking
private var didCancelHaptic = false
private var didLockHaptic = false
// Haptic generator eager init so object is ready long before first use
private let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
}
override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil {
impactFeedback.prepare()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Touch Tracking
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
guard recordingState == .idle else { return false }
touchStartLocation = touch.location(in: window)
lastTouchLocation = touchStartLocation
lastTouchTimestamp = touch.timestamp
velocityX = 0
velocityY = 0
recordingState = .waiting
targetCancelTranslation = 0
targetLockTranslation = 0
currentCancelTranslation = 0
currentLockTranslation = 0
didCancelHaptic = false
didLockHaptic = false
impactFeedback.prepare()
recordingDelegate?.micButtonRecordingArmed(self)
// Start hold timer after 0.19s we begin recording
holdTimer = Timer.scheduledTimer(withTimeInterval: holdThreshold, repeats: false) { [weak self] _ in
self?.beginRecording()
}
return true
}
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
guard recordingState == .waiting || recordingState == .recording else { return false }
let location = touch.location(in: window)
let distanceX = min(0, location.x - touchStartLocation.x)
let distanceY = min(0, location.y - touchStartLocation.y)
updateVelocity(with: touch, at: location)
// Check if we moved enough to cancel the hold timer (before recording started)
if recordingState == .waiting {
let totalDistance = sqrt(distanceX * distanceX + distanceY * distanceY)
if totalDistance > preHoldCancelDistance {
// Movement before threshold cancel the timer, don't start recording
cancelHoldTimer()
recordingState = .idle
recordingDelegate?.micButtonRecordingArmingCancelled(self)
return false
}
return true
}
// Recording state track slide gestures
targetCancelTranslation = distanceX
targetLockTranslation = distanceY
// Cancel haptic
if distanceX < cancelHapticThreshold, !didCancelHaptic {
didCancelHaptic = true
fireHaptic()
}
// Lock haptic
if distanceY < lockHapticThreshold, !didLockHaptic {
didLockHaptic = true
fireHaptic()
}
// Check cancel threshold
if distanceX < cancelDistanceThreshold {
commitCancel()
return false
}
// Check lock threshold
if distanceY < lockDistanceThreshold {
commitLock()
return false
}
return true
}
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
if recordingState == .waiting {
// Released before hold threshold just a tap
cancelHoldTimer()
recordingState = .idle
recordingDelegate?.micButtonRecordingArmingCancelled(self)
return
}
if recordingState == .recording {
// Fallback to distance thresholds on release.
if let touch {
let location = touch.location(in: window)
var distanceX = min(0, location.x - touchStartLocation.x)
var distanceY = min(0, location.y - touchStartLocation.y)
// Telegram parity: keep only dominant direction on release.
(distanceX, distanceY) = VoiceRecordingParityMath.dominantAxisDistances(
distanceX: distanceX,
distanceY: distanceY
)
switch VoiceRecordingParityMath.releaseDecision(
velocityX: velocityX,
velocityY: velocityY,
distanceX: distanceX,
distanceY: distanceY
) {
case .cancel:
commitCancel()
return
case .lock:
commitLock()
return
case .finish:
break
}
}
// Normal release finish recording (send)
commitFinish()
}
}
override func cancelTracking(with event: UIEvent?) {
if recordingState == .recording {
// Telegram parity: delayed lock after cancelTracking.
targetLockTranslation = 0
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
guard let self, self.recordingState == .recording else { return }
self.commitLock()
}
} else {
cancelHoldTimer()
recordingState = .idle
recordingDelegate?.micButtonRecordingArmingCancelled(self)
}
stopDisplayLink()
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
bounds.insetBy(dx: VoiceRecordingParityConstants.micHitInsetX, dy: 0).contains(point)
}
// MARK: - State Transitions
private func beginRecording() {
guard recordingState == .waiting else { return }
recordingState = .recording
holdTimer = nil
// Haptic fires BEFORE delegate delegate starts AVAudioSession
// which suppresses Taptic Engine.
fireHaptic()
startDisplayLink()
recordingDelegate?.micButtonRecordingBegan(self)
}
private func commitCancel() {
guard recordingState == .recording else { return }
recordingState = .cancelled
stopDisplayLink()
recordingDelegate?.micButtonRecordingCancelled(self)
}
private func commitLock() {
guard recordingState == .recording else { return }
recordingState = .locked
stopDisplayLink()
fireHaptic()
recordingDelegate?.micButtonRecordingLocked(self)
}
private func commitFinish() {
guard recordingState == .recording else { return }
recordingState = .finished
stopDisplayLink()
recordingDelegate?.micButtonRecordingFinished(self)
}
// MARK: - Public API
/// Reset to idle state (call after processing send/cancel/lock).
func resetState() {
cancelHoldTimer()
stopDisplayLink()
recordingState = .idle
velocityX = 0
velocityY = 0
targetCancelTranslation = 0
targetLockTranslation = 0
currentCancelTranslation = 0
currentLockTranslation = 0
}
#if DEBUG
func debugSetRecordingState(_ state: VoiceRecordingState) {
recordingState = state
}
#endif
// MARK: - Display Link
private func startDisplayLink() {
guard displayLink == nil else { return }
let link = CADisplayLink(target: self, selector: #selector(displayLinkUpdate))
link.preferredFrameRateRange = CAFrameRateRange(minimum: 24, maximum: 30, preferred: 30)
link.add(to: .main, forMode: .common)
displayLink = link
}
private func stopDisplayLink() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func displayLinkUpdate() {
// Telegram exact: 0.7/0.3 blend (TGModernConversationInputMicButton.m line 918-919)
currentCancelTranslation = min(0, currentCancelTranslation * 0.7 + targetCancelTranslation * 0.3)
currentLockTranslation = min(0, currentLockTranslation * 0.7 + targetLockTranslation * 0.3)
// Report raw smoothed distances for overlay transforms
recordingDelegate?.micButtonDragUpdate(self, distanceX: currentCancelTranslation, distanceY: currentLockTranslation)
}
// MARK: - Helpers
/// Belt-and-suspenders haptic: UIImpactFeedbackGenerator + direct system call.
/// AudioServicesPlaySystemSound(1519) is the "peek" haptic bypasses
/// UIFeedbackGenerator API and hits Taptic Engine directly.
/// Telegram uses this as fallback in HapticFeedback.swift.
private func fireHaptic() {
impactFeedback.impactOccurred()
impactFeedback.prepare()
}
private func cancelHoldTimer() {
holdTimer?.invalidate()
holdTimer = nil
}
private func updateVelocity(with touch: UITouch, at location: CGPoint) {
let dt = max(0.001, touch.timestamp - lastTouchTimestamp)
velocityX = (location.x - lastTouchLocation.x) / dt
velocityY = (location.y - lastTouchLocation.y) / dt
lastTouchLocation = location
lastTouchTimestamp = touch.timestamp
}
}