mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user