From bd621ff7cf7e89362256e277cea6da2179c7cbbb Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Fri, 22 Sep 2023 16:53:17 +0200 Subject: [PATCH] Improved rendering of voice messages (#1782) --- .../RoomScreen/View/SwipeRightAction.swift | 5 +-- .../VoiceMessages/VoiceRoomPlaybackView.swift | 37 +++++++++++++------ .../VoiceMessages/VoiceRoomTimelineView.swift | 4 +- .../Messages/VoiceMessages/WaveformView.swift | 28 +++++++++++--- .../RoomTimelineItemFactory.swift | 2 +- .../Sources/RoomScreenViewModelTests.swift | 2 +- .../test_voiceRoomPlaybackView.1.png | 4 +- .../test_voiceRoomTimelineView.Bubble.png | 4 +- .../test_voiceRoomTimelineView.Plain.png | 4 +- .../PreviewTests/test_waveformView.1.png | 4 +- 10 files changed, 60 insertions(+), 34 deletions(-) diff --git a/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift b/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift index c1e5f5e19..8afc65a3b 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift @@ -24,7 +24,6 @@ struct SwipeRightAction: ViewModifier { private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) @State private var canStartAction = false - @State private var animate = false @GestureState private var dragGestureActive = false @State private var hasReachedActionThreshold = false @@ -40,7 +39,7 @@ struct SwipeRightAction: ViewModifier { func body(content: Content) -> some View { content .offset(x: xOffset, y: 0.0) - .animation(.interactiveSpring().speed(0.5), value: animate) + .animation(.interactiveSpring().speed(0.5), value: xOffset) .gesture(DragGesture() .updating($dragGestureActive) { _, state, _ in // Available actions should be computed on the fly so we use a gesture state change @@ -69,7 +68,6 @@ struct SwipeRightAction: ViewModifier { } else { hasReachedActionThreshold = false } - animate = true } .onEnded { _ in if xOffset > actionThreshold { @@ -77,7 +75,6 @@ struct SwipeRightAction: ViewModifier { } xOffset = 0.0 - animate = false } ) .onChange(of: dragGestureActive, perform: { value in diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackView.swift index aafa63e07..9aaae35d1 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomPlaybackView.swift @@ -33,11 +33,11 @@ struct VoiceRoomPlaybackView: View { var onPlayPause: () -> Void = { } var onSeek: (Double) -> Void = { _ in } - var onWaveformDragStateChanged: (Bool) -> Void = { _ in } + var onScrubbing: (Bool) -> Void = { _ in } private enum DragState: Equatable { case inactive - case pressing + case pressing(progress: Double) case dragging(progress: Double) var progress: Double { @@ -69,6 +69,7 @@ struct VoiceRoomPlaybackView: View { } @GestureState private var dragState = DragState.inactive + @State private var tapProgress: Double = .zero var timeLabelContent: String { // 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)) } + var showWaveformCursor: Bool { + playbackViewState.playing || dragState.isDragging + } + var body: some View { HStack { HStack { @@ -87,18 +92,25 @@ struct VoiceRoomPlaybackView: View { } .padding(.vertical, 6) 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 - .gesture(LongPressGesture() + .gesture(SpatialTapGesture() + .simultaneously(with: LongPressGesture()) .sequenced(before: DragGesture(coordinateSpace: .local)) .updating($dragState) { value, state, _ in switch value { - // Long press begins. - case .first(true): - state = .pressing + // (SpatialTap, LongPress) begins. + case .first(let spatialLongPress) where spatialLongPress.second == true: + // 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. - case .second(true, let drag): - let progress: Double = (drag?.location.x ?? .zero) / geometry.size.width + case .second(let spatialLongPress, let drag) where spatialLongPress.second == true: + 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) // Dragging ended or the long press cancelled. default: @@ -111,9 +123,10 @@ struct VoiceRoomPlaybackView: View { .onChange(of: dragState) { newDragState in switch newDragState { case .inactive: - onWaveformDragStateChanged(false) - case .pressing: - onWaveformDragStateChanged(true) + onScrubbing(false) + case .pressing(let progress): + tapProgress = progress + onScrubbing(true) feedbackGenerator.prepare() sendFeedback = true case .dragging(let progress): diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomTimelineView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomTimelineView.swift index 86c3819ee..3b037e8f3 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomTimelineView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceRoomTimelineView.swift @@ -35,7 +35,7 @@ struct VoiceRoomTimelineView: View { VoiceRoomPlaybackView(playbackViewState: playbackViewState, onPlayPause: onPlaybackPlayPause, onSeek: onPlaybackSeek(_:), - onWaveformDragStateChanged: onPlaybackDragStateChanged(_:)) + onScrubbing: onPlaybackScrubbing(_:)) .fixedSize(horizontal: false, vertical: true) } } @@ -48,7 +48,7 @@ struct VoiceRoomTimelineView: View { context.send(viewAction: .seekAudio(itemID: timelineItem.id, progress: progress)) } - private func onPlaybackDragStateChanged(_ dragging: Bool) { + private func onPlaybackScrubbing(_ dragging: Bool) { if dragging { context.send(viewAction: .disableLongPress(itemID: timelineItem.id)) } else { diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/WaveformView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/WaveformView.swift index 59c16ee1d..0ea4945e8 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/WaveformView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/WaveformView.swift @@ -40,11 +40,12 @@ extension Waveform { } struct WaveformView: View { - let lineWidth: CGFloat = 2 - let linePadding: CGFloat = 2 + private let lineWidth: CGFloat = 2 + private let linePadding: CGFloat = 2 var waveform: Waveform - let minimumGraphAmplitude: CGFloat = 1 + private let minimumGraphAmplitude: CGFloat = 1 var progress: CGFloat = 0.0 + var showCursor = false var body: some View { GeometryReader { geometry in @@ -64,9 +65,9 @@ struct WaveformView: View { var xOffset: CGFloat = lineWidth / 2 var index = 0 - while xOffset < width - lineWidth { + while xOffset <= width { 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.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)) } + // 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 { static var previews: some View { - WaveformView(waveform: Waveform.mockWaveform) + WaveformView(waveform: Waveform.mockWaveform, progress: 0.5) .frame(width: 140, height: 50) } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 124196f77..fe3d07063 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -461,7 +461,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { } return AudioRoomTimelineItemContent(body: messageContent.body, - duration: messageContent.info?.duration ?? 0, + duration: (messageContent.audio?.duration ?? 0) / 1000.0, waveform: waveform, source: MediaSourceProxy(source: messageContent.source, mimeType: messageContent.info?.mimetype), contentType: UTType(mimeType: messageContent.info?.mimetype, fallbackFilename: messageContent.body)) diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index 53b5fcd7b..014fb80d9 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -283,7 +283,7 @@ class RoomScreenViewModelTests: XCTestCase { .store(in: &cancellables) // 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.") viewModel.context.send(viewAction: .tappedOnUser(userID: "bob")) try await deferred.fulfill() diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomPlaybackView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomPlaybackView.1.png index 15034988a..a8e5bc370 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomPlaybackView.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomPlaybackView.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:09dc1ab2bf9b5f9576a7f222ee84bd821ae1e608974a5b341cf2c41ce5bfab5e -size 63967 +oid sha256:0e12529919d3829493278947168ddf881d9b7ae7929de5912a7a9c759c9be1ce +size 62187 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Bubble.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Bubble.png index 714156621..3eabcdc5f 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Bubble.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Bubble.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:de7c4ad957c37ad1f46c965985ed4f8884f86a934595c1d4a2ece836b5d5b2c7 -size 72812 +oid sha256:5da10a2b12265a9e3e40ee408030ebc773c938d70a6cf14c2bf4249296bdf93e +size 72786 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Plain.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Plain.png index 69c21763a..16c32d348 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Plain.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceRoomTimelineView.Plain.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9de9d8875b3a9182562fe07f11777d3cb6132d2c97368f15b7f14ca14004a7af -size 69146 +oid sha256:ccfa620baa535643770bd4db7a3c329493ac6bf0351847c03eaadc4831bc7f68 +size 69227 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_waveformView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_waveformView.1.png index e22b58359..285fb78b4 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_waveformView.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_waveformView.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9632b9c96b960ef1ee36ca7980253aaad8e21ded28d741f5d62b33cc4e8a0eba -size 63913 +oid sha256:82130c80be62ed9d3ba0d54df5fec6803c3bed6ef0dc00d044696938d5c41598 +size 64238