Voice messages UI polish (#1959)

* Add VoiceMessageButton

* Add VoiceMessageRecordBadge

* Make composer transition faster

* Use VoiceMessageButton

* Adjust recording badge

* Update localisation key

* Remove animation in VoiceMessageButton

* Fix preview tests

* Cleanup

* Update preview tests
This commit is contained in:
Alfonso Grillo
2023-10-26 11:05:42 +02:00
committed by GitHub
parent 86bcb82cca
commit a1bbc7ecce
16 changed files with 157 additions and 78 deletions
@@ -0,0 +1,110 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct VoiceMessageButton: View {
@ScaledMetric private var buttonSize: CGFloat
@ScaledMetric private var imageWidth: CGFloat = 12
@ScaledMetric private var imageHeight: CGFloat = 14
enum State {
case loading
case playing
case paused
}
enum Size {
case small
case medium
}
let state: State
let action: () -> Void
init(state: State, size: Size, action: @escaping () -> Void) {
switch size {
case .small:
_buttonSize = .init(wrappedValue: 30)
case .medium:
_buttonSize = .init(wrappedValue: 36)
}
self.state = state
self.action = action
}
var body: some View {
Button(action: action) {
buttonLabel
.frame(width: buttonSize, height: buttonSize)
}
.animation(nil, value: state)
.buttonStyle(.plain)
.disabled(state == .loading)
.background(Circle().foregroundColor(.compound.bgCanvasDefault))
}
@ViewBuilder
private var buttonLabel: some View {
switch state {
case .loading:
ProgressView()
case .playing, .paused:
let imageAsset = state == .playing ? Asset.Images.mediaPause : Asset.Images.mediaPlay
let accessibilityLabel = state == .playing ? L10n.a11yPause : L10n.a11yPlay
let offset: CGFloat = state == .playing ? 0 : 2
Image(asset: imageAsset)
.resizable()
.scaledToFit()
.frame(width: imageWidth, height: imageHeight)
.offset(x: offset)
.foregroundColor(.compound.iconSecondary)
.accessibilityLabel(accessibilityLabel)
}
}
}
extension VoiceMessageButton.State {
init(state: AudioPlayerPlaybackState) {
switch state {
case .loading:
self = .loading
case .playing:
self = .playing
default:
self = .paused
}
}
}
struct VoiceMessageButton_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack {
HStack {
VoiceMessageButton(state: .paused, size: .small, action: { })
VoiceMessageButton(state: .paused, size: .medium, action: { })
}
HStack {
VoiceMessageButton(state: .playing, size: .small, action: { })
VoiceMessageButton(state: .playing, size: .medium, action: { })
}
}
.padding()
.background(Color.gray)
}
}
@@ -85,8 +85,8 @@ struct CompletionSuggestionView: View {
}
func body(content: Content) -> some View {
var topPadding: CGFloat = isFirst ? Constants.topPadding : Constants.listItemPadding
var bottomPadding: CGFloat = Constants.listItemPadding
let topPadding: CGFloat = isFirst ? Constants.topPadding : Constants.listItemPadding
let bottomPadding: CGFloat = Constants.listItemPadding
return content
.padding(.top, topPadding)
@@ -94,7 +94,7 @@ struct ComposerToolbar: View {
}
}
}
.animation(.elementDefault, value: context.viewState.composerMode)
.animation(.linear(duration: 0.15), value: context.viewState.composerMode)
}
private var bottomBar: some View {
@@ -29,9 +29,7 @@ struct VoiceMessagePreviewComposer: View {
let onPlay: () -> Void
let onPause: () -> Void
let onSeek: (Double) -> Void
@ScaledMetric private var playPauseButtonSize = 32
@ScaledMetric private var playPauseImagePadding = 8
@State var dragState: WaveformViewDragState = .inactive
private static let elapsedTimeFormatter: DateFormatter = {
@@ -55,7 +53,9 @@ struct VoiceMessagePreviewComposer: View {
var body: some View {
HStack {
HStack {
playPauseButton
VoiceMessageButton(state: .init(state: playerState.playbackState),
size: .small,
action: onPlayPause)
Text(timeLabelContent)
.lineLimit(1)
.font(.compound.bodySMSemibold)
@@ -112,32 +112,6 @@ struct VoiceMessagePreviewComposer: View {
}
}
@ViewBuilder
private var playPauseButton: some View {
Button {
onPlayPause()
} label: {
ZStack {
Circle()
.foregroundColor(.compound.bgCanvasDefault)
if playerState.playbackState == .loading {
ProgressView()
} else {
Image(asset: playerState.playbackState == .playing ? Asset.Images.mediaPause : Asset.Images.mediaPlay)
.resizable()
.padding(playPauseImagePadding)
.offset(x: playerState.playbackState == .playing ? 0 : 2)
.aspectRatio(contentMode: .fit)
.foregroundColor(.compound.iconSecondary)
.accessibilityLabel(playerState.playbackState == .playing ? L10n.a11yPause : L10n.a11yPlay)
}
}
}
.buttonStyle(.plain)
.disabled(playerState.playbackState == .loading)
.frame(width: playPauseButtonSize, height: playPauseButtonSize)
}
private func onPlayPause() {
if playerState.playbackState == .playing {
onPause()
@@ -43,9 +43,9 @@ struct VoiceMessageRecordingView: View {
var body: some View {
HStack {
Circle()
VoiceMessageRecordingBadge()
.frame(width: recordingIndicatorSize, height: recordingIndicatorSize)
.foregroundColor(.red)
Text(timeLabelContent)
.lineLimit(1)
.font(.compound.bodySMSemibold)
@@ -61,6 +61,21 @@ struct VoiceMessageRecordingView: View {
}
}
private struct VoiceMessageRecordingBadge: View {
@State private var opacity: CGFloat = 0
var body: some View {
Circle()
.foregroundColor(.red)
.opacity(opacity)
.onAppear {
withElementAnimation(.easeOut(duration: 1).repeatForever(autoreverses: true)) {
opacity = 1
}
}
}
}
struct VoiceMessageRecordingView_Previews: PreviewProvider, TestablePreview {
static let recorderState = AudioRecorderState()
@@ -48,7 +48,7 @@ class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLo
let action: () -> Void = { [weak self] in self?.actionsSubject.send(.openSystemSettings) }
state.bindings.alertInfo = .init(locationSharingViewError: .missingAuthorization,
primaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionOpenSettings, action: action))
secondaryButton: .init(title: L10n.commonSettings, action: action))
}
}
}
@@ -962,7 +962,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.bindings.confirmationAlertInfo = .init(id: .init(),
title: L10n.dialogPermissionMicrophoneTitleIos(InfoPlistReader.main.bundleDisplayName),
message: L10n.dialogPermissionMicrophoneDescriptionIos,
primaryButton: .init(title: L10n.actionOpenSettings, action: { [weak self] in self?.openSystemSettings() }),
primaryButton: .init(title: L10n.commonSettings, action: { [weak self] in self?.openSystemSettings() }),
secondaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil))
default:
MXLog.error("failed to start voice message recording: \(error)")
@@ -29,9 +29,6 @@ struct VoiceMessageRoomPlaybackView: View {
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
@State private var sendFeedback = false
@ScaledMetric private var playPauseButtonSize = 32
@ScaledMetric private var playPauseImagePadding = 8
private static let elapsedTimeFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
@@ -66,7 +63,9 @@ struct VoiceMessageRoomPlaybackView: View {
var body: some View {
HStack {
HStack {
playPauseButton
VoiceMessageButton(state: .init(state: playerState.playbackState),
size: .medium,
action: onPlayPause)
Text(timeLabelContent)
.lineLimit(1)
.font(.compound.bodySMSemibold)
@@ -101,32 +100,6 @@ struct VoiceMessageRoomPlaybackView: View {
.padding(.leading, 2)
.padding(.trailing, 8)
}
@ViewBuilder
var playPauseButton: some View {
Button {
onPlayPause()
} label: {
ZStack {
Circle()
.foregroundColor(.compound.bgCanvasDefault)
if playerState.playbackState == .loading {
ProgressView()
} else {
Image(asset: playerState.playbackState == .playing ? Asset.Images.mediaPause : Asset.Images.mediaPlay)
.resizable()
.padding(playPauseImagePadding)
.offset(x: playerState.playbackState == .playing ? 0 : 2)
.aspectRatio(contentMode: .fit)
.foregroundColor(.compound.iconSecondary)
.accessibilityLabel(playerState.playbackState == .playing ? L10n.a11yPause : L10n.a11yPlay)
}
}
}
.disabled(playerState.playbackState == .loading)
.frame(width: playPauseButtonSize,
height: playPauseButtonSize)
}
@ViewBuilder
private var waveformView: some View {