Voice message record button hapitcs (#2013)

* Move tooltip logics in VoiceMessageRecordingButton

* Add hapitc feedback when recording a voice message

* Cleanup
This commit is contained in:
Alfonso Grillo
2023-11-02 12:58:19 +01:00
committed by GitHub
parent 4af8ef2a5f
commit 4d3cb97fbb
4 changed files with 73 additions and 65 deletions
@@ -42,3 +42,9 @@ struct ViewFrameReader: View {
} }
} }
} }
extension View {
func readFrame(_ frame: Binding<CGRect>, in coordinateSpace: CoordinateSpace = .local) -> some View {
background(ViewFrameReader(frame: frame, coordinateSpace: coordinateSpace))
}
}
@@ -27,15 +27,6 @@ struct ComposerToolbar: View {
@ScaledMetric private var sendButtonIconSize = 16 @ScaledMetric private var sendButtonIconSize = 16
@ScaledMetric(relativeTo: .title) private var spinnerSize = 44 @ScaledMetric(relativeTo: .title) private var spinnerSize = 44
@ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30 @ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30
@State private var voiceMessageRecordingStartTime: Date?
@State private var showVoiceMessageRecordingTooltip = false
@ScaledMetric private var voiceMessageTooltipPointerHeight = 6
@State private var voiceMessageRecordingButtonFrame: CGRect = .zero
private let voiceMessageMinimumRecordingDuration = 1.0
private let voiceMessageTooltipDuration = 1.0
@State private var frame: CGRect = .zero @State private var frame: CGRect = .zero
@Environment(\.verticalSizeClass) private var verticalSizeClass @Environment(\.verticalSizeClass) private var verticalSizeClass
@@ -64,12 +55,6 @@ struct ComposerToolbar: View {
.offset(y: -frame.height) .offset(y: -frame.height)
} }
} }
.overlay(alignment: .bottomTrailing) {
if showVoiceMessageRecordingTooltip {
voiceMessageRecordingButtonTooltipView
.offset(y: -frame.height - voiceMessageTooltipPointerHeight)
}
}
.alert(item: $context.alertInfo) .alert(item: $context.alertInfo)
} }
@@ -84,7 +69,7 @@ struct ComposerToolbar: View {
private var topBar: some View { private var topBar: some View {
topBarLayout { topBarLayout {
mainTopBarContent mainTopBarContent
if !context.composerActionsEnabled { if !context.composerActionsEnabled {
if context.viewState.isUploading { if context.viewState.isUploading {
ProgressView() ProgressView()
@@ -95,9 +80,6 @@ struct ComposerToolbar: View {
.padding(.leading, 3) .padding(.leading, 3)
} else { } else {
voiceMessageRecordingButton voiceMessageRecordingButton
.background {
ViewFrameReader(frame: $voiceMessageRecordingButtonFrame, coordinateSpace: .global)
}
.padding(.leading, 4) .padding(.leading, 4)
} }
} }
@@ -121,7 +103,7 @@ struct ComposerToolbar: View {
private var topBarLayout: some Layout { private var topBarLayout: some Layout {
HStackLayout(alignment: .bottom, spacing: 5) HStackLayout(alignment: .bottom, spacing: 5)
} }
@ViewBuilder @ViewBuilder
private var mainTopBarContent: some View { private var mainTopBarContent: some View {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
@@ -139,13 +121,13 @@ struct ComposerToolbar: View {
.padding(.trailing, context.composerActionsEnabled ? 4 : 0) .padding(.trailing, context.composerActionsEnabled ? 4 : 0)
} }
.opacity(context.viewState.isVoiceMessageModeActivated ? 0 : 1) .opacity(context.viewState.isVoiceMessageModeActivated ? 0 : 1)
if context.viewState.isVoiceMessageModeActivated { if context.viewState.isVoiceMessageModeActivated {
voiceMessageContent voiceMessageContent
} }
} }
} }
private var closeRTEButton: some View { private var closeRTEButton: some View {
Button { Button {
context.composerActionsEnabled = false context.composerActionsEnabled = false
@@ -249,7 +231,7 @@ struct ComposerToolbar: View {
} }
// MARK: - Voice message // MARK: - Voice message
@ViewBuilder @ViewBuilder
private var voiceMessageContent: some View { private var voiceMessageContent: some View {
// Display the voice message composer above to keep the focus and keep the keyboard open if it's already open. // Display the voice message composer above to keep the focus and keep the keyboard open if it's already open.
@@ -270,22 +252,17 @@ struct ComposerToolbar: View {
private var voiceMessageRecordingButton: some View { private var voiceMessageRecordingButton: some View {
VoiceMessageRecordingButton { VoiceMessageRecordingButton {
showVoiceMessageRecordingTooltip = false
voiceMessageRecordingStartTime = Date.now
context.send(viewAction: .startVoiceMessageRecording) context.send(viewAction: .startVoiceMessageRecording)
} stopRecording: { } stopRecording: { minimumRecordTimeReached in
if let voiceMessageRecordingStartTime, Date.now.timeIntervalSince(voiceMessageRecordingStartTime) < voiceMessageMinimumRecordingDuration { if minimumRecordTimeReached {
context.send(viewAction: .cancelVoiceMessageRecording)
withElementAnimation {
showVoiceMessageRecordingTooltip = true
}
} else {
context.send(viewAction: .stopVoiceMessageRecording) context.send(viewAction: .stopVoiceMessageRecording)
} else {
context.send(viewAction: .cancelVoiceMessageRecording)
} }
} }
.padding(4) .padding(4)
} }
private var voiceMessageTrashButton: some View { private var voiceMessageTrashButton: some View {
Button(role: .destructive) { Button(role: .destructive) {
context.send(viewAction: .deleteVoiceMessageRecording) context.send(viewAction: .deleteVoiceMessageRecording)
@@ -297,22 +274,6 @@ struct ComposerToolbar: View {
.accessibilityLabel(L10n.a11yDelete) .accessibilityLabel(L10n.a11yDelete)
} }
private var voiceMessageRecordingButtonTooltipView: some View {
VoiceMessageRecordingButtonTooltipView(text: L10n.screenRoomVoiceMessageTooltip,
pointerHeight: voiceMessageTooltipPointerHeight,
pointerLocation: voiceMessageRecordingButtonFrame.midX,
pointerLocationCoordinateSpace: .global)
.allowsHitTesting(false)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + voiceMessageTooltipDuration) {
withElementAnimation {
showVoiceMessageRecordingTooltip = false
}
}
}
.padding(.horizontal, 8)
}
private func voiceMessagePreviewComposer(audioPlayerState: AudioPlayerState, waveform: WaveformSource) -> some View { private func voiceMessagePreviewComposer(audioPlayerState: AudioPlayerState, waveform: WaveformSource) -> some View {
VoiceMessagePreviewComposer(playerState: audioPlayerState, waveform: waveform) { VoiceMessagePreviewComposer(playerState: audioPlayerState, waveform: waveform) {
context.send(viewAction: .startVoiceMessagePlayback) context.send(viewAction: .startVoiceMessagePlayback)
@@ -376,7 +337,7 @@ extension ComposerToolbar {
wysiwygViewModel: wysiwygViewModel, wysiwygViewModel: wysiwygViewModel,
keyCommandHandler: { _ in false }) keyCommandHandler: { _ in false })
} }
static func textWithVoiceMessage(focused: Bool = true) -> ComposerToolbar { static func textWithVoiceMessage(focused: Bool = true) -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel() let wysiwygViewModel = WysiwygComposerViewModel()
var composerViewModel: ComposerToolbarViewModel { var composerViewModel: ComposerToolbarViewModel {
@@ -18,33 +18,72 @@ import Compound
import SwiftUI import SwiftUI
struct VoiceMessageRecordingButton: View { struct VoiceMessageRecordingButton: View {
@State private var buttonPressed = false
var startRecording: (() -> Void)? var startRecording: (() -> Void)?
var stopRecording: (() -> Void)? var stopRecording: ((_ minimumRecordTimeReached: Bool) -> Void)?
@ScaledMetric private var tooltipPointerHeight = 6
@State private var buttonPressed = false
@State private var recordingStartTime: Date?
@State private var showTooltip = false
@State private var frame: CGRect = .zero
private let minimumRecordingDuration = 1.0
private let tooltipDuration = 1.0
private let impactFeedbackGenerator = UIImpactFeedbackGenerator()
private let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
var body: some View { var body: some View {
Button { } label: { Button { } label: {
voiceMessageButtonImage CompoundIcon(buttonPressed ? \.micOnSolid : \.micOnOutline)
.foregroundColor(.compound.iconSecondary)
.padding(EdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6))
} }
.readFrame($frame, in: .global)
.accessibilityLabel(L10n.a11yVoiceMessageRecord)
.onLongPressGesture { } onPressingChanged: { isPressing in .onLongPressGesture { } onPressingChanged: { isPressing in
buttonPressed = isPressing buttonPressed = isPressing
if isPressing { if isPressing {
// Start recording showTooltip = false
recordingStartTime = Date.now
impactFeedbackGenerator.impactOccurred()
startRecording?() startRecording?()
} else { } else {
// Stop recording if let recordingStartTime, Date.now.timeIntervalSince(recordingStartTime) < minimumRecordingDuration {
stopRecording?() withElementAnimation {
showTooltip = true
}
notificationFeedbackGenerator.notificationOccurred(.error)
stopRecording?(false)
} else {
impactFeedbackGenerator.impactOccurred()
stopRecording?(true)
}
}
}
.overlay(alignment: .bottomTrailing) {
if showTooltip {
tooltipView
.offset(y: -frame.height - tooltipPointerHeight)
} }
} }
} }
@ViewBuilder private var tooltipView: some View {
private var voiceMessageButtonImage: some View { VoiceMessageRecordingButtonTooltipView(text: L10n.screenRoomVoiceMessageTooltip,
CompoundIcon(buttonPressed ? \.micOnSolid : \.micOnOutline) pointerHeight: tooltipPointerHeight,
.foregroundColor(.compound.iconSecondary) pointerLocation: frame.midX,
.padding(EdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6)) pointerLocationCoordinateSpace: .global)
.accessibilityLabel(L10n.a11yVoiceMessageRecord) .allowsHitTesting(false)
.fixedSize()
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + tooltipDuration) {
withElementAnimation {
showTooltip = false
}
}
}
} }
} }
@@ -141,8 +141,10 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue] AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue]
do { do {
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default) let audioSession = AVAudioSession.sharedInstance()
try AVAudioSession.sharedInstance().setActive(true) try audioSession.setAllowHapticsAndSystemSoundsDuringRecording(true)
try audioSession.setCategory(.playAndRecord, mode: .default)
try audioSession.setActive(true)
let url = URL.temporaryDirectory.appendingPathComponent("voice-message-\(recordID.identifier).m4a") let url = URL.temporaryDirectory.appendingPathComponent("voice-message-\(recordID.identifier).m4a")
let audioRecorder = try AVAudioRecorder(url: url, settings: settings) let audioRecorder = try AVAudioRecorder(url: url, settings: settings)
audioRecorder.delegate = self audioRecorder.delegate = self