SwiftUI语音录制手势

SwiftUI 语音录制手势实现方案 #

实现类似微信的"按住说话、上划取消"功能时,SwiftUI 原生手势存在局限性。本文对比两种实现方案的完整代码。

问题背景 #

原始 DragGesture 实现的问题 #

// ❌ 问题代码
.gesture(
    DragGesture(minimumDistance: 0)
        .onChanged { value in
            // 问题1: 语义不匹配 - DragGesture 用于拖动,不是长按
            // 问题2: minimumDistance=0 是 hack,导致边界条件多
            // 问题3: onChanged 频繁触发,性能差
            // 问题4: 状态管理复杂,需要手动追踪多个变量
        }
)

方案一:UILongPressGestureRecognizer(推荐) #

完整实现代码 #

import SwiftUI
import UIKit

// MARK: - 语音录制手势修饰器
struct VoiceRecordGestureModifier: ViewModifier {
    @ObservedObject var viewModel: ChattingBarViewModel
    @Binding var pressStatus: PressStatus
    let onStart: () -> Void
    let onEnd: (Bool) -> Void  // Bool: 是否取消
    
    func body(content: Content) -> some View {
        content.overlay(
            VoiceRecordGestureView(
                viewModel: viewModel,
                pressStatus: $pressStatus,
                onStart: onStart,
                onEnd: onEnd
            )
            .allowsHitTesting(true)
        )
    }
}

// MARK: - UIKit 手势视图
struct VoiceRecordGestureView: UIViewRepresentable {
    @ObservedObject var viewModel: ChattingBarViewModel
    @Binding var pressStatus: PressStatus
    let onStart: () -> Void
    let onEnd: (Bool) -> Void
    
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        view.backgroundColor = .clear
        
        let longPressGesture = UILongPressGestureRecognizer(
            target: context.coordinator,
            action: #selector(Coordinator.handleLongPress(_:))
        )
        
        // 关键参数设置
        longPressGesture.minimumPressDuration = 0.1  // 100ms,平衡响应速度和误触
        longPressGesture.allowableMovement = .greatestFiniteMagnitude  // 允许无限移动
        
        view.addGestureRecognizer(longPressGesture)
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        context.coordinator.viewModel = viewModel
        context.coordinator.onStart = onStart
        context.coordinator.onEnd = onEnd
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(
            viewModel: viewModel,
            pressStatus: $pressStatus,
            onStart: onStart,
            onEnd: onEnd
        )
    }
    
    // MARK: - Coordinator
    class Coordinator: NSObject {
        var viewModel: ChattingBarViewModel
        @Binding var pressStatus: PressStatus
        var onStart: () -> Void
        var onEnd: (Bool) -> Void
        
        private let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
        private var initialLocation: CGPoint?
        private let cancelThreshold: CGFloat = -50
        private var hasVibratedForCancel = false
        
        init(viewModel: ChattingBarViewModel,
             pressStatus: Binding<PressStatus>,
             onStart: @escaping () -> Void,
             onEnd: @escaping (Bool) -> Void) {
            self.viewModel = viewModel
            self._pressStatus = pressStatus
            self.onStart = onStart
            self.onEnd = onEnd
            super.init()
            feedbackGenerator.prepare()
        }
        
        @objc func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
            let location = gesture.location(in: gesture.view)
            
            switch gesture.state {
            case .began:
                handleBegan(location: location)
                
            case .changed:
                handleChanged(location: location)
                
            case .ended:
                handleEnded()
                
            case .cancelled, .failed:
                handleCancelled()
                
            default:
                break
            }
        }
        
        private func handleBegan(location: CGPoint) {
            initialLocation = location
            hasVibratedForCancel = false
            
            // 震动反馈
            feedbackGenerator.impactOccurred()
            
            // 更新状态
            DispatchQueue.main.async { [weak self] in
                withAnimation(.easeInOut(duration: 0.1)) {
                    self?.pressStatus = .pressing
                }
                self?.onStart()
            }
        }
        
        private func handleChanged(location: CGPoint) {
            guard let initial = initialLocation else { return }
            let deltaY = location.y - initial.y
            
            if deltaY < cancelThreshold {
                // 进入取消区域
                if pressStatus != .cancelled {
                    if !hasVibratedForCancel {
                        feedbackGenerator.impactOccurred()
                        hasVibratedForCancel = true
                    }
                    DispatchQueue.main.async { [weak self] in
                        withAnimation(.easeInOut(duration: 0.1)) {
                            self?.pressStatus = .cancelled
                        }
                    }
                }
            } else {
                // 回到正常区域
                hasVibratedForCancel = false
                if pressStatus != .pressing {
                    DispatchQueue.main.async { [weak self] in
                        withAnimation(.easeInOut(duration: 0.1)) {
                            self?.pressStatus = .pressing
                        }
                    }
                }
            }
        }
        
        private func handleEnded() {
            let isCancelled = pressStatus == .cancelled
            
            DispatchQueue.main.async { [weak self] in
                self?.onEnd(isCancelled)
                withAnimation(.easeInOut(duration: 0.15)) {
                    self?.pressStatus = .none
                }
            }
        }
        
        private func handleCancelled() {
            // 系统中断(来电、通知等)
            DispatchQueue.main.async { [weak self] in
                self?.onEnd(true)
                withAnimation(.easeInOut(duration: 0.15)) {
                    self?.pressStatus = .none
                }
            }
        }
    }
}

// MARK: - View Extension
extension View {
    func voiceRecordGesture(
        viewModel: ChattingBarViewModel,
        pressStatus: Binding<PressStatus>,
        onStart: @escaping () -> Void,
        onEnd: @escaping (Bool) -> Void
    ) -> some View {
        self.modifier(VoiceRecordGestureModifier(
            viewModel: viewModel,
            pressStatus: pressStatus,
            onStart: onStart,
            onEnd: onEnd
        ))
    }
}

// MARK: - 使用示例
struct ChatInputBar: View {
    @StateObject private var viewModel = ChattingBarViewModel()
    @State private var pressStatus: PressStatus = .none
    
    var body: some View {
        HStack {
            if pressStatus == .none {
                Text("按住说话")
            } else if pressStatus == .cancelled {
                Text("松开取消")
                    .foregroundColor(.red)
            } else {
                Text("正在录音...")
                    .foregroundColor(.green)
            }
        }
        .frame(height: 50)
        .frame(maxWidth: .infinity)
        .background(Color.gray.opacity(0.1))
        .voiceRecordGesture(
            viewModel: viewModel,
            pressStatus: $pressStatus,
            onStart: {
                // 开始录音
                startRecording()
            },
            onEnd: { isCancelled in
                if isCancelled {
                    cancelRecording()
                } else {
                    sendRecording()
                }
            }
        )
    }
    
    func startRecording() {
        // 检查权限
        AVAudioSession.sharedInstance().requestRecordPermission { granted in
            if granted {
                // 开始录音逻辑
            }
        }
    }
    
    func cancelRecording() {
        // 取消录音
    }
    
    func sendRecording() {
        // 发送录音
    }
}

enum PressStatus {
    case none
    case pressing
    case cancelled
}

方案二:Touch Events(零延迟) #

完整实现代码 #

import SwiftUI
import UIKit

// MARK: - 自定义触摸跟踪视图
class TouchTrackingView: UIView {
    weak var coordinator: VoiceRecordGestureView.Coordinator?
    private var initialLocation: CGPoint?
    private var isTracking = false
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
        guard let touch = touches.first else { return }
        
        let location = touch.location(in: self)
        initialLocation = location
        isTracking = true
        coordinator?.handleTouchBegan(location: location)
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesMoved(touches, with: event)
        guard isTracking, 
              let touch = touches.first, 
              let initial = initialLocation else { return }
        
        let location = touch.location(in: self)
        let deltaY = location.y - initial.y
        coordinator?.handleTouchMoved(deltaY: deltaY)
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)
        if isTracking {
            coordinator?.handleTouchEnded()
            isTracking = false
            initialLocation = nil
        }
    }
    
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesCancelled(touches, with: event)
        if isTracking {
            coordinator?.handleTouchCancelled()
            isTracking = false
            initialLocation = nil
        }
    }
}

// MARK: - 语音录制手势修饰器(Touch Events版)
struct VoiceRecordTouchModifier: ViewModifier {
    @ObservedObject var viewModel: ChattingBarViewModel
    @Binding var pressStatus: PressStatus
    let onStart: () -> Void
    let onEnd: (Bool) -> Void
    
    func body(content: Content) -> some View {
        content.overlay(
            VoiceRecordTouchView(
                viewModel: viewModel,
                pressStatus: $pressStatus,
                onStart: onStart,
                onEnd: onEnd
            )
            .allowsHitTesting(true)
        )
    }
}

// MARK: - UIKit Touch 视图
struct VoiceRecordTouchView: UIViewRepresentable {
    @ObservedObject var viewModel: ChattingBarViewModel
    @Binding var pressStatus: PressStatus
    let onStart: () -> Void
    let onEnd: (Bool) -> Void
    
    func makeUIView(context: Context) -> UIView {
        let view = TouchTrackingView()
        view.backgroundColor = .clear
        view.coordinator = context.coordinator
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        if let touchView = uiView as? TouchTrackingView {
            touchView.coordinator = context.coordinator
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(
            viewModel: viewModel,
            pressStatus: $pressStatus,
            onStart: onStart,
            onEnd: onEnd
        )
    }
    
    // MARK: - Coordinator
    class Coordinator: NSObject {
        var viewModel: ChattingBarViewModel
        @Binding var pressStatus: PressStatus
        var onStart: () -> Void
        var onEnd: (Bool) -> Void
        
        private let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
        private let cancelThreshold: CGFloat = -50
        private var hasVibratedForCancel = false
        private var isRecording = false
        
        init(viewModel: ChattingBarViewModel,
             pressStatus: Binding<PressStatus>,
             onStart: @escaping () -> Void,
             onEnd: @escaping (Bool) -> Void) {
            self.viewModel = viewModel
            self._pressStatus = pressStatus
            self.onStart = onStart
            self.onEnd = onEnd
            super.init()
            feedbackGenerator.prepare()
        }
        
        func handleTouchBegan(location: CGPoint) {
            guard !isRecording else { return }
            
            isRecording = true
            hasVibratedForCancel = false
            
            // 立即震动反馈
            feedbackGenerator.impactOccurred()
            
            // 立即更新UI
            DispatchQueue.main.async { [weak self] in
                withAnimation(.easeInOut(duration: 0.1)) {
                    self?.pressStatus = .pressing
                }
                self?.onStart()
            }
        }
        
        func handleTouchMoved(deltaY: CGFloat) {
            guard isRecording else { return }
            
            if deltaY < cancelThreshold {
                // 进入取消区域
                if pressStatus != .cancelled {
                    if !hasVibratedForCancel {
                        feedbackGenerator.impactOccurred()
                        hasVibratedForCancel = true
                    }
                    DispatchQueue.main.async { [weak self] in
                        withAnimation(.easeInOut(duration: 0.1)) {
                            self?.pressStatus = .cancelled
                        }
                    }
                }
            } else {
                // 回到正常区域
                hasVibratedForCancel = false
                if pressStatus != .pressing {
                    DispatchQueue.main.async { [weak self] in
                        withAnimation(.easeInOut(duration: 0.1)) {
                            self?.pressStatus = .pressing
                        }
                    }
                }
            }
        }
        
        func handleTouchEnded() {
            guard isRecording else { return }
            
            let isCancelled = pressStatus == .cancelled
            isRecording = false
            
            DispatchQueue.main.async { [weak self] in
                self?.onEnd(isCancelled)
                withAnimation(.easeInOut(duration: 0.15)) {
                    self?.pressStatus = .none
                }
            }
        }
        
        func handleTouchCancelled() {
            guard isRecording else { return }
            
            isRecording = false
            
            DispatchQueue.main.async { [weak self] in
                self?.onEnd(true)
                withAnimation(.easeInOut(duration: 0.15)) {
                    self?.pressStatus = .none
                }
            }
        }
    }
}

// MARK: - View Extension
extension View {
    func voiceRecordTouch(
        viewModel: ChattingBarViewModel,
        pressStatus: Binding<PressStatus>,
        onStart: @escaping () -> Void,
        onEnd: @escaping (Bool) -> Void
    ) -> some View {
        self.modifier(VoiceRecordTouchModifier(
            viewModel: viewModel,
            pressStatus: pressStatus,
            onStart: onStart,
            onEnd: onEnd
        ))
    }
}

完整的 ViewModel 实现 #

import SwiftUI
import AVFoundation

class ChattingBarViewModel: ObservableObject {
    @Published var isRecording: Bool = false
    @Published var recordingTime: TimeInterval = 0
    
    private var audioRecorder: AVAudioRecorder?
    private var timer: Timer?
    
    func checkRecordPermission() -> Bool {
        AVAudioSession.sharedInstance().recordPermission == .granted
    }
    
    func requestRecordPermission(completion: @escaping (Bool) -> Void) {
        AVAudioSession.sharedInstance().requestRecordPermission { granted in
            DispatchQueue.main.async {
                completion(granted)
            }
        }
    }
    
    func startRecording() {
        guard !isRecording else { return }
        
        // 配置音频会话
        let session = AVAudioSession.sharedInstance()
        do {
            try session.setCategory(.playAndRecord, mode: .default)
            try session.setActive(true)
        } catch {
            print("Failed to set up audio session: \(error)")
            return
        }
        
        // 配置录音器
        let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        let audioFilename = documentsPath.appendingPathComponent("recording_\(Date().timeIntervalSince1970).m4a")
        
        let settings: [String: Any] = [
            AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
            AVSampleRateKey: 44100,
            AVNumberOfChannelsKey: 2,
            AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
        ]
        
        do {
            audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings)
            audioRecorder?.prepareToRecord()
            audioRecorder?.record()
            
            isRecording = true
            recordingTime = 0
            
            // 启动计时器
            timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
                self?.recordingTime += 0.1
            }
        } catch {
            print("Failed to start recording: \(error)")
        }
    }
    
    func stopRecording(isCancelled: Bool) {
        guard isRecording else { return }
        
        audioRecorder?.stop()
        audioRecorder = nil
        
        timer?.invalidate()
        timer = nil
        
        isRecording = false
        recordingTime = 0
        
        if !isCancelled {
            // 处理录音文件,发送等
            sendAudioMessage()
        }
    }
    
    private func sendAudioMessage() {
        // 发送录音逻辑
    }
}

性能对比 #

方案响应延迟CPU占用内存占用代码复杂度
DragGesture~50ms高(频繁onChanged)
UILongPressGestureRecognizer~10ms
Touch Events0ms最低最低

SwiftUI 的架构权衡 #

响应链层级 #

硬件触摸 → Touch Events → Gesture Recognizers → SwiftUI Gestures
(最快)                                          (最慢但最便捷)

为什么需要桥接 #

  1. 抽象层的代价:SwiftUI 手势是高层抽象,牺牲了部分底层控制
  2. 跨平台考虑:API 设计需要兼容未来可能的多平台
  3. 状态管理:SwiftUI 倾向于简化状态,但复杂交互需要更细粒度控制

最佳实践建议 #

选择方案 #

  • 一般应用:使用 UILongPressGestureRecognizer(方案一)
  • 极致体验:使用 Touch Events(方案二)
  • 简单交互:使用 SwiftUI 原生手势

优化要点 #

  1. 预热震动反馈
feedbackGenerator.prepare()  // 提前准备,减少延迟
  1. 滞后区间防抖
// 避免在阈值边界反复触发
let enterCancelThreshold: CGFloat = -50
let exitCancelThreshold: CGFloat = -40  // 滞后10点
  1. 权限预检查
// 在 App 启动时预先请求权限,避免录音时中断
func preloadPermissions() {
    _ = AVAudioSession.sharedInstance().recordPermission
}

总结 #

SwiftUI 与 UIKit 的桥接不是妥协,而是实用主义的体现。在 SwiftUI 生态成熟之前,善用 UIKit 能力是构建优秀用户体验的必要手段。

“正确的抽象胜过巧妙的实现” - 选择合适的原语比优化错误的方案重要得多。

参考资料 #