Improved rendering of voice messages (#1782)

This commit is contained in:
Nicolas Mauri
2023-09-22 16:53:17 +02:00
committed by GitHub
parent bc1910e9bb
commit e9ce24fd9c
10 changed files with 60 additions and 34 deletions
@@ -24,7 +24,6 @@ struct SwipeRightAction<Label: View>: ViewModifier {
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
@State private var canStartAction = false @State private var canStartAction = false
@State private var animate = false
@GestureState private var dragGestureActive = false @GestureState private var dragGestureActive = false
@State private var hasReachedActionThreshold = false @State private var hasReachedActionThreshold = false
@@ -40,7 +39,7 @@ struct SwipeRightAction<Label: View>: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.offset(x: xOffset, y: 0.0) .offset(x: xOffset, y: 0.0)
.animation(.interactiveSpring().speed(0.5), value: animate) .animation(.interactiveSpring().speed(0.5), value: xOffset)
.gesture(DragGesture() .gesture(DragGesture()
.updating($dragGestureActive) { _, state, _ in .updating($dragGestureActive) { _, state, _ in
// Available actions should be computed on the fly so we use a gesture state change // Available actions should be computed on the fly so we use a gesture state change
@@ -69,7 +68,6 @@ struct SwipeRightAction<Label: View>: ViewModifier {
} else { } else {
hasReachedActionThreshold = false hasReachedActionThreshold = false
} }
animate = true
} }
.onEnded { _ in .onEnded { _ in
if xOffset > actionThreshold { if xOffset > actionThreshold {
@@ -77,7 +75,6 @@ struct SwipeRightAction<Label: View>: ViewModifier {
} }
xOffset = 0.0 xOffset = 0.0
animate = false
} }
) )
.onChange(of: dragGestureActive, perform: { value in .onChange(of: dragGestureActive, perform: { value in
@@ -33,11 +33,11 @@ struct VoiceRoomPlaybackView: View {
var onPlayPause: () -> Void = { } var onPlayPause: () -> Void = { }
var onSeek: (Double) -> Void = { _ in } var onSeek: (Double) -> Void = { _ in }
var onWaveformDragStateChanged: (Bool) -> Void = { _ in } var onScrubbing: (Bool) -> Void = { _ in }
private enum DragState: Equatable { private enum DragState: Equatable {
case inactive case inactive
case pressing case pressing(progress: Double)
case dragging(progress: Double) case dragging(progress: Double)
var progress: Double { var progress: Double {
@@ -69,6 +69,7 @@ struct VoiceRoomPlaybackView: View {
} }
@GestureState private var dragState = DragState.inactive @GestureState private var dragState = DragState.inactive
@State private var tapProgress: Double = .zero
var timeLabelContent: String { var timeLabelContent: String {
// Display the duration if progress is 0.0 // Display the duration if progress is 0.0
@@ -76,6 +77,10 @@ struct VoiceRoomPlaybackView: View {
return Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: playbackViewState.duration * percent)) return Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: playbackViewState.duration * percent))
} }
var showWaveformCursor: Bool {
playbackViewState.playing || dragState.isDragging
}
var body: some View { var body: some View {
HStack { HStack {
HStack { HStack {
@@ -87,18 +92,25 @@ struct VoiceRoomPlaybackView: View {
} }
.padding(.vertical, 6) .padding(.vertical, 6)
GeometryReader { geometry in GeometryReader { geometry in
WaveformView(waveform: playbackViewState.waveform, progress: playbackViewState.progress) WaveformView(waveform: playbackViewState.waveform, progress: playbackViewState.progress, showCursor: showWaveformCursor)
// Add a gesture to drag the waveform // Add a gesture to drag the waveform
.gesture(LongPressGesture() .gesture(SpatialTapGesture()
.simultaneously(with: LongPressGesture())
.sequenced(before: DragGesture(coordinateSpace: .local)) .sequenced(before: DragGesture(coordinateSpace: .local))
.updating($dragState) { value, state, _ in .updating($dragState) { value, state, _ in
switch value { switch value {
// Long press begins. // (SpatialTap, LongPress) begins.
case .first(true): case .first(let spatialLongPress) where spatialLongPress.second == true:
state = .pressing // Compute the progress with the spatialTap location
let progress = (spatialLongPress.first?.location ?? .zero).x / geometry.size.width
state = .pressing(progress: progress)
// Long press confirmed, dragging may begin. // Long press confirmed, dragging may begin.
case .second(true, let drag): case .second(let spatialLongPress, let drag) where spatialLongPress.second == true:
let progress: Double = (drag?.location.x ?? .zero) / geometry.size.width var progress: Double = tapProgress
// Compute the progress with drag location
if let loc = drag?.location {
progress = loc.x / geometry.size.width
}
state = .dragging(progress: progress) state = .dragging(progress: progress)
// Dragging ended or the long press cancelled. // Dragging ended or the long press cancelled.
default: default:
@@ -111,9 +123,10 @@ struct VoiceRoomPlaybackView: View {
.onChange(of: dragState) { newDragState in .onChange(of: dragState) { newDragState in
switch newDragState { switch newDragState {
case .inactive: case .inactive:
onWaveformDragStateChanged(false) onScrubbing(false)
case .pressing: case .pressing(let progress):
onWaveformDragStateChanged(true) tapProgress = progress
onScrubbing(true)
feedbackGenerator.prepare() feedbackGenerator.prepare()
sendFeedback = true sendFeedback = true
case .dragging(let progress): case .dragging(let progress):
@@ -35,7 +35,7 @@ struct VoiceRoomTimelineView: View {
VoiceRoomPlaybackView(playbackViewState: playbackViewState, VoiceRoomPlaybackView(playbackViewState: playbackViewState,
onPlayPause: onPlaybackPlayPause, onPlayPause: onPlaybackPlayPause,
onSeek: onPlaybackSeek(_:), onSeek: onPlaybackSeek(_:),
onWaveformDragStateChanged: onPlaybackDragStateChanged(_:)) onScrubbing: onPlaybackScrubbing(_:))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
} }
@@ -48,7 +48,7 @@ struct VoiceRoomTimelineView: View {
context.send(viewAction: .seekAudio(itemID: timelineItem.id, progress: progress)) context.send(viewAction: .seekAudio(itemID: timelineItem.id, progress: progress))
} }
private func onPlaybackDragStateChanged(_ dragging: Bool) { private func onPlaybackScrubbing(_ dragging: Bool) {
if dragging { if dragging {
context.send(viewAction: .disableLongPress(itemID: timelineItem.id)) context.send(viewAction: .disableLongPress(itemID: timelineItem.id))
} else { } else {
@@ -40,11 +40,12 @@ extension Waveform {
} }
struct WaveformView: View { struct WaveformView: View {
let lineWidth: CGFloat = 2 private let lineWidth: CGFloat = 2
let linePadding: CGFloat = 2 private let linePadding: CGFloat = 2
var waveform: Waveform var waveform: Waveform
let minimumGraphAmplitude: CGFloat = 1 private let minimumGraphAmplitude: CGFloat = 1
var progress: CGFloat = 0.0 var progress: CGFloat = 0.0
var showCursor = false
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
@@ -64,9 +65,9 @@ struct WaveformView: View {
var xOffset: CGFloat = lineWidth / 2 var xOffset: CGFloat = lineWidth / 2
var index = 0 var index = 0
while xOffset < width - lineWidth { while xOffset <= width {
let sample = CGFloat(index >= normalisedData.count ? 0 : normalisedData[index]) let sample = CGFloat(index >= normalisedData.count ? 0 : normalisedData[index])
let drawingAmplitude = max(minimumGraphAmplitude, sample * height) let drawingAmplitude = max(minimumGraphAmplitude, sample * (height - 2))
path.move(to: CGPoint(x: xOffset, y: centerY - drawingAmplitude / 2)) path.move(to: CGPoint(x: xOffset, y: centerY - drawingAmplitude / 2))
path.addLine(to: CGPoint(x: xOffset, y: centerY + drawingAmplitude / 2)) path.addLine(to: CGPoint(x: xOffset, y: centerY + drawingAmplitude / 2))
@@ -76,13 +77,28 @@ struct WaveformView: View {
} }
.stroke(Color.compound.iconSecondary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) .stroke(Color.compound.iconSecondary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
} }
// Display a cursor
.overlay(alignment: .leading) {
RoundedRectangle(cornerRadius: 1).fill(Color.compound.iconAccentTertiary)
.offset(CGSize(width: cursorPosition(progress: progress, width: geometry.size.width), height: 0.0))
.frame(width: lineWidth, height: geometry.size.height)
.opacity(showCursor ? 1 : 0)
}
} }
} }
private func cursorPosition(progress: Double, width: Double) -> Double {
guard progress > 0 else {
return 0
}
let width = (width * progress)
return width - width.truncatingRemainder(dividingBy: lineWidth + linePadding)
}
} }
struct WaveformView_Previews: PreviewProvider, TestablePreview { struct WaveformView_Previews: PreviewProvider, TestablePreview {
static var previews: some View { static var previews: some View {
WaveformView(waveform: Waveform.mockWaveform) WaveformView(waveform: Waveform.mockWaveform, progress: 0.5)
.frame(width: 140, height: 50) .frame(width: 140, height: 50)
} }
} }
@@ -461,7 +461,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
} }
return AudioRoomTimelineItemContent(body: messageContent.body, return AudioRoomTimelineItemContent(body: messageContent.body,
duration: messageContent.info?.duration ?? 0, duration: (messageContent.audio?.duration ?? 0) / 1000.0,
waveform: waveform, waveform: waveform,
source: MediaSourceProxy(source: messageContent.source, mimeType: messageContent.info?.mimetype), source: MediaSourceProxy(source: messageContent.source, mimeType: messageContent.info?.mimetype),
contentType: UTType(mimeType: messageContent.info?.mimetype, fallbackFilename: messageContent.body)) contentType: UTType(mimeType: messageContent.info?.mimetype, fallbackFilename: messageContent.body))
@@ -283,7 +283,7 @@ class RoomScreenViewModelTests: XCTestCase {
.store(in: &cancellables) .store(in: &cancellables)
// Test // Test
let deferred = deferFulfillment(viewModel.context.$viewState.collect(2).first(), let deferred = deferFulfillment(viewModel.context.$viewState.collect(3).first(),
message: "The existing view state plus one new one should be published.") message: "The existing view state plus one new one should be published.")
viewModel.context.send(viewAction: .tappedOnUser(userID: "bob")) viewModel.context.send(viewAction: .tappedOnUser(userID: "bob"))
try await deferred.fulfill() try await deferred.fulfill()
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:09dc1ab2bf9b5f9576a7f222ee84bd821ae1e608974a5b341cf2c41ce5bfab5e oid sha256:0e12529919d3829493278947168ddf881d9b7ae7929de5912a7a9c759c9be1ce
size 63967 size 62187
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:de7c4ad957c37ad1f46c965985ed4f8884f86a934595c1d4a2ece836b5d5b2c7 oid sha256:5da10a2b12265a9e3e40ee408030ebc773c938d70a6cf14c2bf4249296bdf93e
size 72812 size 72786
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9de9d8875b3a9182562fe07f11777d3cb6132d2c97368f15b7f14ca14004a7af oid sha256:ccfa620baa535643770bd4db7a3c329493ac6bf0351847c03eaadc4831bc7f68
size 69146 size 69227
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9632b9c96b960ef1ee36ca7980253aaad8e21ded28d741f5d62b33cc4e8a0eba oid sha256:82130c80be62ed9d3ba0d54df5fec6803c3bed6ef0dc00d044696938d5c41598
size 63913 size 64238