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 Events | 0ms | 最低 | 最低 | 高 |
SwiftUI 的架构权衡 #
响应链层级 #
硬件触摸 → Touch Events → Gesture Recognizers → SwiftUI Gestures
(最快) (最慢但最便捷)
为什么需要桥接 #
- 抽象层的代价:SwiftUI 手势是高层抽象,牺牲了部分底层控制
- 跨平台考虑:API 设计需要兼容未来可能的多平台
- 状态管理:SwiftUI 倾向于简化状态,但复杂交互需要更细粒度控制
最佳实践建议 #
选择方案 #
- 一般应用:使用 UILongPressGestureRecognizer(方案一)
- 极致体验:使用 Touch Events(方案二)
- 简单交互:使用 SwiftUI 原生手势
优化要点 #
- 预热震动反馈
feedbackGenerator.prepare() // 提前准备,减少延迟
- 滞后区间防抖
// 避免在阈值边界反复触发
let enterCancelThreshold: CGFloat = -50
let exitCancelThreshold: CGFloat = -40 // 滞后10点
- 权限预检查
// 在 App 启动时预先请求权限,避免录音时中断
func preloadPermissions() {
_ = AVAudioSession.sharedInstance().recordPermission
}
总结 #
SwiftUI 与 UIKit 的桥接不是妥协,而是实用主义的体现。在 SwiftUI 生态成熟之前,善用 UIKit 能力是构建优秀用户体验的必要手段。
“正确的抽象胜过巧妙的实现” - 选择合适的原语比优化错误的方案重要得多。