Merge pull request #4515 from vector-im/feature/adm/voice-state

Voice recording UI state in ViewModel
This commit is contained in:
Benoit Marty 2021-11-19 15:59:17 +01:00 committed by GitHub
commit 35f9bef94a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 760 additions and 591 deletions

1
changelog.d/4515.misc Normal file
View File

@ -0,0 +1 @@
Voice recording mic button refactor with small animation tweaks in preparation for voice drafts

View File

@ -138,7 +138,8 @@ import im.vector.app.features.home.room.detail.composer.TextComposerView
import im.vector.app.features.home.room.detail.composer.TextComposerViewEvents
import im.vector.app.features.home.room.detail.composer.TextComposerViewModel
import im.vector.app.features.home.room.detail.composer.TextComposerViewState
import im.vector.app.features.home.room.detail.composer.VoiceMessageRecorderView
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.action.EventSharedAction
@ -505,7 +506,7 @@ class RoomDetailFragment @Inject constructor(
private fun onCannotRecord() {
// Update the UI, cancel the animation
views.voiceMessageRecorderView.initVoiceRecordingViews()
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingUiStateChanged(RecordingUiState.None))
}
private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) {
@ -692,33 +693,57 @@ class RoomDetailFragment @Inject constructor(
}
private fun setupVoiceMessageView() {
views.voiceMessageRecorderView.voiceMessagePlaybackTracker = voiceMessagePlaybackTracker
voiceMessagePlaybackTracker.track(VoiceMessagePlaybackTracker.RECORDING_ID, views.voiceMessageRecorderView)
views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback {
override fun onVoiceRecordingStarted(): Boolean {
return if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
override fun onVoiceRecordingStarted() {
if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(true))
vibrate(requireContext())
true
} else {
// Permission dialog is displayed
false
updateRecordingUiState(RecordingUiState.Started)
}
}
override fun onVoiceRecordingEnded(isCancelled: Boolean) {
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled))
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(false))
}
override fun onVoiceRecordingPlaybackModeOn() {
roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
}
override fun onVoicePlaybackButtonClicked() {
roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback)
}
override fun onVoiceRecordingCancelled() {
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true))
updateRecordingUiState(RecordingUiState.Cancelled)
}
override fun onVoiceRecordingLocked() {
updateRecordingUiState(RecordingUiState.Locked)
}
override fun onVoiceRecordingEnded() {
onSendVoiceMessage()
}
override fun onSendVoiceMessage() {
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = false))
updateRecordingUiState(RecordingUiState.None)
}
override fun onDeleteVoiceMessage() {
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true))
updateRecordingUiState(RecordingUiState.None)
}
override fun onRecordingLimitReached() {
roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
updateRecordingUiState(RecordingUiState.Playback)
}
override fun onRecordingWaveformClicked() {
roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
updateRecordingUiState(RecordingUiState.Playback)
}
private fun updateRecordingUiState(state: RecordingUiState) {
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingUiStateChanged(state))
}
}
}
@ -1112,7 +1137,7 @@ class RoomDetailFragment @Inject constructor(
// We should improve the UX to support going into playback mode when paused and delete the media when the view is destroyed.
roomDetailViewModel.handle(RoomDetailAction.EndAllVoiceActions(deleteRecord = false))
views.voiceMessageRecorderView.initVoiceRecordingViews()
views.voiceMessageRecorderView.display(RecordingUiState.None)
}
private val attachmentFileActivityResultLauncher = registerStartForActivityResult {
@ -1408,6 +1433,7 @@ class RoomDetailFragment @Inject constructor(
views.composerLayout.isInvisible = !textComposerState.isComposerVisible
views.voiceMessageRecorderView.isVisible = textComposerState.isVoiceMessageRecorderVisible
views.composerLayout.views.sendButton.isInvisible = !textComposerState.isSendButtonVisible
views.voiceMessageRecorderView.display(textComposerState.voiceRecordingUiState)
views.composerLayout.setRoomEncrypted(summary.isEncrypted)
// views.composerLayout.alwaysShowSendButton = false
if (textComposerState.canSendMessage) {
@ -1962,7 +1988,7 @@ class RoomDetailFragment @Inject constructor(
roomDetailViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
}
is EventSharedAction.Edit -> {
if (!views.voiceMessageRecorderView.isActive()) {
if (withState(textComposerViewModel) { it.isVoiceMessageIdle }) {
textComposerViewModel.handle(TextComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString()))
} else {
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
@ -1972,7 +1998,7 @@ class RoomDetailFragment @Inject constructor(
textComposerViewModel.handle(TextComposerAction.EnterQuoteMode(action.eventId, views.composerLayout.text.toString()))
}
is EventSharedAction.Reply -> {
if (!views.voiceMessageRecorderView.isActive()) {
if (withState(textComposerViewModel) { it.isVoiceMessageIdle }) {
textComposerViewModel.handle(TextComposerAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString()))
} else {
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)

View File

@ -17,6 +17,7 @@
package im.vector.app.features.home.room.detail.composer
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
sealed class TextComposerAction : VectorViewModelAction {
data class SaveDraft(val draft: String) : TextComposerAction()
@ -27,5 +28,5 @@ sealed class TextComposerAction : VectorViewModelAction {
data class EnterRegularMode(val text: String, val fromSharing: Boolean) : TextComposerAction()
data class UserIsTyping(val isTyping: Boolean) : TextComposerAction()
data class OnTextChanged(val text: CharSequence) : TextComposerAction()
data class OnVoiceRecordingStateChanged(val isRecording: Boolean) : TextComposerAction()
data class OnVoiceRecordingUiStateChanged(val uiState: VoiceMessageRecorderView.RecordingUiState) : TextComposerAction()
}

View File

@ -77,20 +77,20 @@ class TextComposerViewModel @AssistedInject constructor(
override fun handle(action: TextComposerAction) {
Timber.v("Handle action: $action")
when (action) {
is TextComposerAction.EnterEditMode -> handleEnterEditMode(action)
is TextComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action)
is TextComposerAction.EnterRegularMode -> handleEnterRegularMode(action)
is TextComposerAction.EnterReplyMode -> handleEnterReplyMode(action)
is TextComposerAction.SaveDraft -> handleSaveDraft(action)
is TextComposerAction.SendMessage -> handleSendMessage(action)
is TextComposerAction.UserIsTyping -> handleUserIsTyping(action)
is TextComposerAction.OnTextChanged -> handleOnTextChanged(action)
is TextComposerAction.OnVoiceRecordingStateChanged -> handleOnVoiceRecordingStateChanged(action)
is TextComposerAction.EnterEditMode -> handleEnterEditMode(action)
is TextComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action)
is TextComposerAction.EnterRegularMode -> handleEnterRegularMode(action)
is TextComposerAction.EnterReplyMode -> handleEnterReplyMode(action)
is TextComposerAction.SaveDraft -> handleSaveDraft(action)
is TextComposerAction.SendMessage -> handleSendMessage(action)
is TextComposerAction.UserIsTyping -> handleUserIsTyping(action)
is TextComposerAction.OnTextChanged -> handleOnTextChanged(action)
is TextComposerAction.OnVoiceRecordingUiStateChanged -> handleOnVoiceRecordingUiStateChanged(action)
}
}
private fun handleOnVoiceRecordingStateChanged(action: TextComposerAction.OnVoiceRecordingStateChanged) = setState {
copy(isVoiceRecording = action.isRecording)
private fun handleOnVoiceRecordingUiStateChanged(action: TextComposerAction.OnVoiceRecordingUiStateChanged) = setState {
copy(voiceRecordingUiState = action.uiState)
}
private fun handleOnTextChanged(action: TextComposerAction.OnTextChanged) {

View File

@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.composer
import com.airbnb.mvrx.MavericksState
import im.vector.app.features.home.room.detail.RoomDetailArgs
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
/**
@ -44,13 +45,27 @@ sealed class SendMode(open val text: String) {
data class TextComposerViewState(
val roomId: String,
val canSendMessage: Boolean = true,
val isVoiceRecording: Boolean = false,
val isSendButtonVisible: Boolean = false,
val sendMode: SendMode = SendMode.REGULAR("", false)
val sendMode: SendMode = SendMode.REGULAR("", false),
val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.None
) : MavericksState {
val isVoiceRecording = when (voiceRecordingUiState) {
VoiceMessageRecorderView.RecordingUiState.None,
VoiceMessageRecorderView.RecordingUiState.Cancelled,
VoiceMessageRecorderView.RecordingUiState.Playback -> false
VoiceMessageRecorderView.RecordingUiState.Locked,
VoiceMessageRecorderView.RecordingUiState.Started -> true
}
val isVoiceMessageIdle = when (voiceRecordingUiState) {
VoiceMessageRecorderView.RecordingUiState.None, VoiceMessageRecorderView.RecordingUiState.Cancelled -> false
else -> true
}
val isComposerVisible = canSendMessage && !isVoiceRecording
val isVoiceMessageRecorderVisible = canSendMessage && !isSendButtonVisible
@Suppress("UNUSED") // needed by mavericks
constructor(args: RoomDetailArgs) : this(roomId = args.roomId)
}

View File

@ -1,551 +0,0 @@
/*
* Copyright (c) 2021 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.
*/
package im.vector.app.features.home.room.detail.composer
import android.content.Context
import android.text.format.DateUtils
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.extensions.setAttributeBackground
import im.vector.app.core.extensions.setAttributeTintedBackground
import im.vector.app.core.extensions.setAttributeTintedImageResource
import im.vector.app.core.hardware.vibrate
import im.vector.app.core.utils.CountUpTimer
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import org.matrix.android.sdk.api.extensions.orFalse
import timber.log.Timber
import kotlin.math.abs
import kotlin.math.floor
/**
* Encapsulates the voice message recording view and animations.
*/
class VoiceMessageRecorderView : ConstraintLayout, VoiceMessagePlaybackTracker.Listener {
interface Callback {
// Return true if the recording is started
fun onVoiceRecordingStarted(): Boolean
fun onVoiceRecordingEnded(isCancelled: Boolean)
fun onVoiceRecordingPlaybackModeOn()
fun onVoicePlaybackButtonClicked()
}
private lateinit var views: ViewVoiceMessageRecorderBinding
var callback: Callback? = null
var voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker? = null
set(value) {
field = value
value?.track(VoiceMessagePlaybackTracker.RECORDING_ID, this)
}
private var recordingState: RecordingState = RecordingState.NONE
private var firstX: Float = 0f
private var firstY: Float = 0f
private var lastX: Float = 0f
private var lastY: Float = 0f
private var lastDistanceX: Float = 0f
private var lastDistanceY: Float = 0f
private var recordingTicker: CountUpTimer? = null
private val dimensionConverter = DimensionConverter(context.resources)
private val minimumMove = dimensionConverter.dpToPx(16)
private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
private val rtlXMultiplier = context.resources.getInteger(R.integer.rtl_x_multiplier)
// Don't convert to primary constructor.
// We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
@JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : super(context, attrs, defStyleAttr) {
initialize()
}
fun initialize() {
inflate(context, R.layout.view_voice_message_recorder, this)
views = ViewVoiceMessageRecorderBinding.bind(this)
initVoiceRecordingViews()
initListeners()
}
override fun onVisibilityChanged(changedView: View, visibility: Int) {
super.onVisibilityChanged(changedView, visibility)
// onVisibilityChanged is called by constructor on api 21 and 22.
if (!this::views.isInitialized) return
if (changedView == this && visibility == VISIBLE) {
views.voiceMessageMicButton.contentDescription = context.getString(R.string.a11y_start_voice_message)
} else {
views.voiceMessageMicButton.contentDescription = ""
}
}
fun initVoiceRecordingViews() {
recordingState = RecordingState.NONE
hideRecordingViews(null)
stopRecordingTicker()
views.voiceMessageMicButton.isVisible = true
views.voiceMessageSendButton.isVisible = false
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }
}
private fun initListeners() {
views.voiceMessageSendButton.setOnClickListener {
stopRecordingTicker()
hideRecordingViews(isCancelled = false)
views.voiceMessageSendButton.isVisible = false
recordingState = RecordingState.NONE
}
views.voiceMessageDeletePlayback.setOnClickListener {
stopRecordingTicker()
hideRecordingViews(isCancelled = true)
views.voiceMessageSendButton.isVisible = false
recordingState = RecordingState.NONE
}
views.voicePlaybackWaveform.setOnClickListener {
if (recordingState != RecordingState.PLAYBACK) {
recordingState = RecordingState.PLAYBACK
showPlaybackViews()
}
}
views.voicePlaybackControlButton.setOnClickListener {
callback?.onVoicePlaybackButtonClicked()
}
views.voiceMessageMicButton.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
handleMicActionDown(event)
true
}
MotionEvent.ACTION_UP -> {
handleMicActionUp()
true
}
MotionEvent.ACTION_MOVE -> {
if (recordingState == RecordingState.CANCELLED) return@setOnTouchListener false
handleMicActionMove(event)
true
}
else ->
false
}
}
}
private fun handleMicActionDown(event: MotionEvent) {
val recordingStarted = callback?.onVoiceRecordingStarted().orFalse()
if (recordingStarted) {
startRecordingTicker()
renderToast(context.getString(R.string.voice_message_release_to_send_toast))
recordingState = RecordingState.STARTED
showRecordingViews()
firstX = event.rawX
firstY = event.rawY
lastX = firstX
lastY = firstY
lastDistanceX = 0F
lastDistanceY = 0F
}
}
private fun handleMicActionUp() {
if (recordingState != RecordingState.LOCKED && recordingState != RecordingState.NONE) {
stopRecordingTicker()
val isCancelled = recordingState == RecordingState.NONE || recordingState == RecordingState.CANCELLED
recordingState = RecordingState.NONE
hideRecordingViews(isCancelled = isCancelled)
}
}
private fun handleMicActionMove(event: MotionEvent) {
val currentX = event.rawX
val currentY = event.rawY
val distanceX = abs(firstX - currentX)
val distanceY = abs(firstY - currentY)
val isRecordingStateChanged = updateRecordingState(currentX, currentY, distanceX, distanceY)
when (recordingState) {
RecordingState.CANCELLING -> {
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
val reducedAlpha = (1 - translationAmount / distanceToCancel / 1.5).toFloat()
views.voiceMessageSlideToCancel.alpha = reducedAlpha
views.voiceMessageTimerIndicator.alpha = reducedAlpha
views.voiceMessageTimer.alpha = reducedAlpha
views.voiceMessageLockBackground.isVisible = false
views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockArrow.isVisible = false
// Reset Y translations
views.voiceMessageMicButton.translationY = 0F
views.voiceMessageLockArrow.translationY = 0F
}
RecordingState.LOCKING -> {
views.voiceMessageLockImage.setAttributeTintedImageResource(R.drawable.ic_voice_message_locked, R.attr.colorPrimary)
val translationAmount = -distanceY.coerceIn(0F, distanceToLock)
views.voiceMessageMicButton.translationY = translationAmount
views.voiceMessageLockArrow.translationY = translationAmount
views.voiceMessageLockArrow.alpha = 1 - (-translationAmount / distanceToLock)
// Reset X translations
views.voiceMessageMicButton.translationX = 0F
views.voiceMessageSlideToCancel.translationX = 0F
}
RecordingState.CANCELLED -> {
hideRecordingViews(isCancelled = true)
vibrate(context)
}
RecordingState.LOCKED -> {
if (isRecordingStateChanged) { // Do not update views if it was already in locked state.
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_locked)
views.voiceMessageLockImage.postDelayed({
showRecordingLockedViews()
}, 500)
}
}
RecordingState.STARTED -> {
showRecordingViews()
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
}
RecordingState.NONE -> Timber.d("VoiceMessageRecorderView shouldn't be in NONE state while moving.")
RecordingState.PLAYBACK -> Timber.d("VoiceMessageRecorderView shouldn't be in PLAYBACK state while moving.")
}
lastX = currentX
lastY = currentY
lastDistanceX = distanceX
lastDistanceY = distanceY
}
private fun updateRecordingState(currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): Boolean {
val previousRecordingState = recordingState
if (recordingState == RecordingState.STARTED) {
// Determine if cancelling or locking for the first move action.
if (((currentX < firstX && rtlXMultiplier == 1) || (currentX > firstX && rtlXMultiplier == -1)) &&
distanceX > distanceY && distanceX > lastDistanceX) {
recordingState = RecordingState.CANCELLING
} else if (currentY < firstY && distanceY > distanceX && distanceY > lastDistanceY) {
recordingState = RecordingState.LOCKING
}
} else if (recordingState == RecordingState.CANCELLING) {
// Check if cancelling conditions met, also check if it should be initial state
if (distanceX < minimumMove && distanceX < lastDistanceX) {
recordingState = RecordingState.STARTED
} else if (shouldCancelRecording(distanceX)) {
recordingState = RecordingState.CANCELLED
}
} else if (recordingState == RecordingState.LOCKING) {
// Check if locking conditions met, also check if it should be initial state
if (distanceY < minimumMove && distanceY < lastDistanceY) {
recordingState = RecordingState.STARTED
} else if (shouldLockRecording(distanceY)) {
recordingState = RecordingState.LOCKED
}
}
return previousRecordingState != recordingState
}
private fun shouldCancelRecording(distanceX: Float): Boolean {
return distanceX >= distanceToCancel
}
private fun shouldLockRecording(distanceY: Float): Boolean {
return distanceY >= distanceToLock
}
private fun startRecordingTicker() {
recordingTicker?.stop()
recordingTicker = CountUpTimer().apply {
tickListener = object : CountUpTimer.TickListener {
override fun onTick(milliseconds: Long) {
onRecordingTick(milliseconds)
}
}
resume()
}
onRecordingTick(0L)
}
private fun onRecordingTick(milliseconds: Long) {
renderRecordingTimer(milliseconds / 1_000)
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
if (timeDiffToRecordingLimit <= 0) {
views.voiceMessageRecordingLayout.post {
recordingState = RecordingState.PLAYBACK
showPlaybackViews()
stopRecordingTicker()
}
} else if (timeDiffToRecordingLimit in 10_000..10_999) {
views.voiceMessageRecordingLayout.post {
renderToast(context.getString(R.string.voice_message_n_seconds_warning_toast, floor(timeDiffToRecordingLimit / 1000f).toInt()))
vibrate(context)
}
}
}
private fun renderToast(message: String) {
views.voiceMessageToast.removeCallbacks(hideToastRunnable)
views.voiceMessageToast.text = message
views.voiceMessageToast.isVisible = true
views.voiceMessageToast.postDelayed(hideToastRunnable, 2_000)
}
private fun hideToast() {
views.voiceMessageToast.isVisible = false
}
private val hideToastRunnable = Runnable {
views.voiceMessageToast.isVisible = false
}
private fun renderRecordingTimer(recordingTimeMillis: Long) {
val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis)
if (recordingState == RecordingState.LOCKED) {
views.voicePlaybackTime.apply {
post {
text = formattedTimerText
}
}
} else {
views.voiceMessageTimer.post {
views.voiceMessageTimer.text = formattedTimerText
}
}
}
private fun renderRecordingWaveform(amplitudeList: Array<Int>) {
post {
views.voicePlaybackWaveform.apply {
amplitudeList.iterator().forEach {
update(it)
}
}
}
}
private fun stopRecordingTicker() {
recordingTicker?.stop()
recordingTicker = null
}
private fun showRecordingViews() {
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording)
views.voiceMessageMicButton.setAttributeTintedBackground(R.drawable.circle_with_halo, R.attr.colorPrimary)
views.voiceMessageMicButton.updateLayoutParams<MarginLayoutParams> {
setMargins(0, 0, 0, 0)
}
views.voiceMessageMicButton.animate().scaleX(1.5f).scaleY(1.5f).setDuration(300).start()
views.voiceMessageLockBackground.isVisible = true
views.voiceMessageLockBackground.animate().setDuration(300).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
views.voiceMessageLockImage.isVisible = true
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked)
views.voiceMessageLockImage.animate().setDuration(500).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
views.voiceMessageLockArrow.isVisible = true
views.voiceMessageLockArrow.alpha = 1f
views.voiceMessageSlideToCancel.isVisible = true
views.voiceMessageTimerIndicator.isVisible = true
views.voiceMessageTimer.isVisible = true
views.voiceMessageSlideToCancel.alpha = 1f
views.voiceMessageTimerIndicator.alpha = 1f
views.voiceMessageTimer.alpha = 1f
views.voiceMessageSendButton.isVisible = false
}
private fun hideRecordingViews(isCancelled: Boolean?) {
// We need to animate the lock image first
if (recordingState != RecordingState.LOCKED || isCancelled.orFalse()) {
views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockImage.animate().translationY(0f).start()
views.voiceMessageLockBackground.isVisible = false
views.voiceMessageLockBackground.animate().translationY(0f).start()
} else {
animateLockImageWithBackground()
}
views.voiceMessageLockArrow.isVisible = false
views.voiceMessageLockArrow.animate().translationY(0f).start()
views.voiceMessageSlideToCancel.isVisible = false
views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
views.voiceMessagePlaybackLayout.isVisible = false
if (recordingState != RecordingState.LOCKED) {
views.voiceMessageMicButton
.animate()
.scaleX(1f)
.scaleY(1f)
.translationX(0f)
.translationY(0f)
.setDuration(150)
.withEndAction {
views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false
resetMicButtonUi()
isCancelled?.let {
callback?.onVoiceRecordingEnded(it)
}
}
.start()
} else {
views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false
views.voiceMessageMicButton.apply {
scaleX = 1f
scaleY = 1f
translationX = 0f
translationY = 0f
}
isCancelled?.let {
callback?.onVoiceRecordingEnded(it)
}
}
// Hide toasts if user cancelled recording before the timeout of the toast.
if (recordingState == RecordingState.CANCELLED || recordingState == RecordingState.NONE) {
hideToast()
}
}
private fun resetMicButtonUi() {
views.voiceMessageMicButton.isVisible = true
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic)
views.voiceMessageMicButton.setAttributeBackground(android.R.attr.selectableItemBackgroundBorderless)
views.voiceMessageMicButton.updateLayoutParams<MarginLayoutParams> {
if (rtlXMultiplier == -1) {
// RTL
setMargins(dimensionConverter.dpToPx(12), 0, 0, dimensionConverter.dpToPx(12))
} else {
setMargins(0, 0, dimensionConverter.dpToPx(12), dimensionConverter.dpToPx(12))
}
}
}
private fun animateLockImageWithBackground() {
views.voiceMessageLockBackground.updateLayoutParams {
height = dimensionConverter.dpToPx(78)
}
views.voiceMessageLockBackground.apply {
animate()
.scaleX(0f)
.scaleY(0f)
.setDuration(400L)
.withEndAction {
updateLayoutParams {
height = dimensionConverter.dpToPx(180)
}
isVisible = false
scaleX = 1f
scaleY = 1f
animate().translationY(0f).start()
}
.start()
}
// Lock image animation
views.voiceMessageMicButton.isInvisible = true
views.voiceMessageLockImage.apply {
isVisible = true
animate()
.scaleX(0f)
.scaleY(0f)
.setDuration(400L)
.withEndAction {
isVisible = false
scaleX = 1f
scaleY = 1f
translationY = 0f
resetMicButtonUi()
}
.start()
}
}
private fun showRecordingLockedViews() {
hideRecordingViews(null)
views.voiceMessagePlaybackLayout.isVisible = true
views.voiceMessagePlaybackTimerIndicator.isVisible = true
views.voicePlaybackControlButton.isVisible = false
views.voiceMessageSendButton.isVisible = true
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
renderToast(context.getString(R.string.voice_message_tap_to_stop_toast))
}
private fun showPlaybackViews() {
views.voiceMessagePlaybackTimerIndicator.isVisible = false
views.voicePlaybackControlButton.isVisible = true
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
callback?.onVoiceRecordingPlaybackModeOn()
}
private enum class RecordingState {
NONE,
STARTED,
CANCELLING,
CANCELLED,
LOCKING,
LOCKED,
PLAYBACK
}
/**
* Returns true if the voice message is recording or is in playback mode
*/
fun isActive() = recordingState !in listOf(RecordingState.NONE, RecordingState.CANCELLED)
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
when (state) {
is VoiceMessagePlaybackTracker.Listener.State.Recording -> {
renderRecordingWaveform(state.amplitudeList.toTypedArray())
}
is VoiceMessagePlaybackTracker.Listener.State.Playing -> {
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
views.voicePlaybackControlButton.contentDescription = context.getString(R.string.a11y_pause_voice_message)
val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong())
views.voicePlaybackTime.text = formattedTimerText
}
is VoiceMessagePlaybackTracker.Listener.State.Paused,
is VoiceMessagePlaybackTracker.Listener.State.Idle -> {
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
views.voicePlaybackControlButton.contentDescription = context.getString(R.string.a11y_play_voice_message)
}
}
}
}

View File

@ -0,0 +1,101 @@
/*
* Copyright (c) 2021 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.
*/
package im.vector.app.features.home.room.detail.composer.voice
import android.content.res.Resources
import android.view.MotionEvent
import im.vector.app.R
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.DraggingState
class DraggableStateProcessor(
resources: Resources,
dimensionConverter: DimensionConverter,
) {
private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
private val rtlXMultiplier = resources.getInteger(R.integer.rtl_x_multiplier)
private var firstX: Float = 0f
private var firstY: Float = 0f
private var lastDistanceX: Float = 0f
private var lastDistanceY: Float = 0f
fun initialize(event: MotionEvent) {
firstX = event.rawX
firstY = event.rawY
lastDistanceX = 0F
lastDistanceY = 0F
}
fun process(event: MotionEvent, draggingState: DraggingState): DraggingState {
val currentX = event.rawX
val currentY = event.rawY
val distanceX = firstX - currentX
val distanceY = firstY - currentY
return draggingState.nextDragState(currentX, currentY, distanceX, distanceY).also {
lastDistanceX = distanceX
lastDistanceY = distanceY
}
}
private fun DraggingState.nextDragState(currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): DraggingState {
return when (this) {
DraggingState.Ready -> {
when {
isDraggingToCancel(currentX, distanceX, distanceY) -> DraggingState.Cancelling(distanceX)
isDraggingToLock(currentY, distanceX, distanceY) -> DraggingState.Locking(distanceY)
else -> DraggingState.Ready
}
}
is DraggingState.Cancelling -> {
when {
isDraggingToLock(currentY, distanceX, distanceY) -> DraggingState.Locking(distanceY)
shouldCancelRecording(distanceX) -> DraggingState.Cancel
else -> DraggingState.Cancelling(distanceX)
}
}
is DraggingState.Locking -> {
when {
isDraggingToCancel(currentX, distanceX, distanceY) -> DraggingState.Cancelling(distanceX)
shouldLockRecording(distanceY) -> DraggingState.Lock
else -> DraggingState.Locking(distanceY)
}
}
else -> {
this
}
}
}
private fun isDraggingToLock(currentY: Float, distanceX: Float, distanceY: Float) = (currentY < firstY) &&
distanceY > distanceX && distanceY > lastDistanceY
private fun isDraggingToCancel(currentX: Float, distanceX: Float, distanceY: Float) = isDraggingHorizontal(currentX) &&
distanceX > distanceY && distanceX > lastDistanceX
private fun isDraggingHorizontal(currentX: Float) = (currentX < firstX && rtlXMultiplier == 1) || (currentX > firstX && rtlXMultiplier == -1)
private fun shouldCancelRecording(distanceX: Float): Boolean {
return distanceX >= distanceToCancel
}
private fun shouldLockRecording(distanceY: Float): Boolean {
return distanceY >= distanceToLock
}
}

View File

@ -0,0 +1,227 @@
/*
* Copyright (c) 2021 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.
*/
package im.vector.app.features.home.room.detail.composer.voice
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.hardware.vibrate
import im.vector.app.core.utils.CountUpTimer
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import kotlin.math.floor
/**
* Encapsulates the voice message recording view and animations.
*/
class VoiceMessageRecorderView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), VoiceMessagePlaybackTracker.Listener {
interface Callback {
fun onVoiceRecordingStarted()
fun onVoiceRecordingEnded()
fun onVoicePlaybackButtonClicked()
fun onVoiceRecordingCancelled()
fun onVoiceRecordingLocked()
fun onSendVoiceMessage()
fun onDeleteVoiceMessage()
fun onRecordingLimitReached()
fun onRecordingWaveformClicked()
}
// We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
@Suppress("UNNECESSARY_LATEINIT")
private lateinit var voiceMessageViews: VoiceMessageViews
lateinit var callback: Callback
private var recordingTicker: CountUpTimer? = null
private var lastKnownState: RecordingUiState? = null
private var dragState: DraggingState = DraggingState.Ignored
init {
inflate(this.context, R.layout.view_voice_message_recorder, this)
val dimensionConverter = DimensionConverter(this.context.resources)
voiceMessageViews = VoiceMessageViews(
this.context.resources,
ViewVoiceMessageRecorderBinding.bind(this),
dimensionConverter
)
initListeners()
}
private fun initListeners() {
voiceMessageViews.start(object : VoiceMessageViews.Actions {
override fun onRequestRecording() = callback.onVoiceRecordingStarted()
override fun onMicButtonReleased() {
when (dragState) {
DraggingState.Lock -> {
// do nothing,
// onSendVoiceMessage, onDeleteVoiceMessage or onRecordingLimitReached will be triggered instead
}
DraggingState.Cancel -> callback.onVoiceRecordingCancelled()
else -> callback.onVoiceRecordingEnded()
}
}
override fun onSendVoiceMessage() = callback.onSendVoiceMessage()
override fun onDeleteVoiceMessage() = callback.onDeleteVoiceMessage()
override fun onWaveformClicked() = callback.onRecordingWaveformClicked()
override fun onVoicePlaybackButtonClicked() = callback.onVoicePlaybackButtonClicked()
override fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState) {
onDrag(dragState, newDragState = nextDragStateCreator(dragState))
}
})
}
override fun onVisibilityChanged(changedView: View, visibility: Int) {
super.onVisibilityChanged(changedView, visibility)
// onVisibilityChanged is called by constructor on api 21 and 22.
if (!this::voiceMessageViews.isInitialized) return
val parentChanged = changedView == this
voiceMessageViews.renderVisibilityChanged(parentChanged, visibility)
}
fun display(recordingState: RecordingUiState) {
if (lastKnownState == recordingState) return
lastKnownState = recordingState
when (recordingState) {
RecordingUiState.None -> {
reset()
}
RecordingUiState.Started -> {
startRecordingTicker()
voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast))
voiceMessageViews.showRecordingViews()
dragState = DraggingState.Ready
}
RecordingUiState.Cancelled -> {
reset()
vibrate(context)
}
RecordingUiState.Locked -> {
voiceMessageViews.renderLocked()
postDelayed({
voiceMessageViews.showRecordingLockedViews(recordingState)
}, 500)
}
RecordingUiState.Playback -> {
stopRecordingTicker()
voiceMessageViews.showPlaybackViews()
}
}
}
private fun reset() {
stopRecordingTicker()
voiceMessageViews.initViews()
dragState = DraggingState.Ignored
}
private fun onDrag(currentDragState: DraggingState, newDragState: DraggingState) {
when (newDragState) {
is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(newDragState.distanceX)
is DraggingState.Locking -> {
if (currentDragState is DraggingState.Cancelling) {
voiceMessageViews.showRecordingViews()
}
voiceMessageViews.renderLocking(newDragState.distanceY)
}
DraggingState.Cancel -> callback.onVoiceRecordingCancelled()
DraggingState.Lock -> callback.onVoiceRecordingLocked()
DraggingState.Ignored,
DraggingState.Ready -> {
// do nothing
}
}.exhaustive
dragState = newDragState
}
private fun startRecordingTicker() {
recordingTicker?.stop()
recordingTicker = CountUpTimer().apply {
tickListener = object : CountUpTimer.TickListener {
override fun onTick(milliseconds: Long) {
onRecordingTick(milliseconds)
}
}
resume()
}
onRecordingTick(0L)
}
private fun onRecordingTick(milliseconds: Long) {
val currentState = lastKnownState ?: return
voiceMessageViews.renderRecordingTimer(currentState, milliseconds / 1_000)
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
if (timeDiffToRecordingLimit <= 0) {
post {
callback.onRecordingLimitReached()
}
} else if (timeDiffToRecordingLimit in 10_000..10_999) {
post {
val secondsRemaining = floor(timeDiffToRecordingLimit / 1000f).toInt()
voiceMessageViews.renderToast(context.getString(R.string.voice_message_n_seconds_warning_toast, secondsRemaining))
vibrate(context)
}
}
}
private fun stopRecordingTicker() {
recordingTicker?.stop()
recordingTicker = null
}
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
when (state) {
is VoiceMessagePlaybackTracker.Listener.State.Recording -> {
voiceMessageViews.renderRecordingWaveform(state.amplitudeList.toTypedArray())
}
is VoiceMessagePlaybackTracker.Listener.State.Playing -> {
voiceMessageViews.renderPlaying(state)
}
is VoiceMessagePlaybackTracker.Listener.State.Paused,
is VoiceMessagePlaybackTracker.Listener.State.Idle -> {
voiceMessageViews.renderIdle()
}
}
}
sealed interface RecordingUiState {
object None : RecordingUiState
object Started : RecordingUiState
object Cancelled : RecordingUiState
object Locked : RecordingUiState
object Playback : RecordingUiState
}
sealed interface DraggingState {
object Ready : DraggingState
object Ignored : DraggingState
data class Cancelling(val distanceX: Float) : DraggingState
data class Locking(val distanceY: Float) : DraggingState
object Cancel : DraggingState
object Lock : DraggingState
}
}

View File

@ -0,0 +1,349 @@
/*
* Copyright (c) 2021 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.
*/
package im.vector.app.features.home.room.detail.composer.voice
import android.annotation.SuppressLint
import android.content.res.Resources
import android.text.format.DateUtils
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import im.vector.app.R
import im.vector.app.core.extensions.setAttributeBackground
import im.vector.app.core.extensions.setAttributeTintedBackground
import im.vector.app.core.extensions.setAttributeTintedImageResource
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.DraggingState
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
class VoiceMessageViews(
private val resources: Resources,
private val views: ViewVoiceMessageRecorderBinding,
private val dimensionConverter: DimensionConverter,
) {
private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
private val rtlXMultiplier = resources.getInteger(R.integer.rtl_x_multiplier)
fun start(actions: Actions) {
views.voiceMessageSendButton.setOnClickListener {
views.voiceMessageSendButton.isVisible = false
actions.onSendVoiceMessage()
}
views.voiceMessageDeletePlayback.setOnClickListener {
views.voiceMessageSendButton.isVisible = false
actions.onDeleteVoiceMessage()
}
views.voicePlaybackWaveform.setOnClickListener {
actions.onWaveformClicked()
}
views.voicePlaybackControlButton.setOnClickListener {
actions.onVoicePlaybackButtonClicked()
}
observeMicButton(actions)
}
@SuppressLint("ClickableViewAccessibility")
private fun observeMicButton(actions: Actions) {
val draggableStateProcessor = DraggableStateProcessor(resources, dimensionConverter)
views.voiceMessageMicButton.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
draggableStateProcessor.initialize(event)
actions.onRequestRecording()
true
}
MotionEvent.ACTION_UP -> {
actions.onMicButtonReleased()
true
}
MotionEvent.ACTION_MOVE -> {
actions.onMicButtonDrag { currentState -> draggableStateProcessor.process(event, currentState) }
true
}
else -> false
}
}
}
fun renderStarted(distanceX: Float) {
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
}
fun renderLocked() {
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_locked)
}
fun renderLocking(distanceY: Float) {
views.voiceMessageLockImage.setAttributeTintedImageResource(R.drawable.ic_voice_message_locked, R.attr.colorPrimary)
val translationAmount = -distanceY.coerceIn(0F, distanceToLock)
views.voiceMessageMicButton.translationY = translationAmount
views.voiceMessageLockArrow.translationY = translationAmount
views.voiceMessageLockArrow.alpha = 1 - (-translationAmount / distanceToLock)
// Reset X translations
views.voiceMessageMicButton.translationX = 0F
views.voiceMessageSlideToCancel.translationX = 0F
}
fun renderCancelling(distanceX: Float) {
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
val reducedAlpha = (1 - translationAmount / distanceToCancel / 1.5).toFloat()
views.voiceMessageSlideToCancel.alpha = reducedAlpha
views.voiceMessageTimerIndicator.alpha = reducedAlpha
views.voiceMessageTimer.alpha = reducedAlpha
views.voiceMessageLockBackground.isVisible = false
views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockArrow.isVisible = false
views.voiceMessageSlideToCancelDivider.isVisible = true
// Reset Y translations
views.voiceMessageMicButton.translationY = 0F
views.voiceMessageLockArrow.translationY = 0F
}
fun showRecordingViews() {
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording)
views.voiceMessageMicButton.setAttributeTintedBackground(R.drawable.circle_with_halo, R.attr.colorPrimary)
views.voiceMessageMicButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
setMargins(0, 0, 0, 0)
}
views.voiceMessageMicButton.animate().scaleX(1.5f).scaleY(1.5f).setDuration(300).start()
views.voiceMessageLockBackground.isVisible = true
views.voiceMessageLockBackground.animate().setDuration(300).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
views.voiceMessageLockImage.isVisible = true
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked)
views.voiceMessageLockImage.animate().setDuration(500).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
views.voiceMessageLockArrow.isVisible = true
views.voiceMessageLockArrow.alpha = 1f
views.voiceMessageSlideToCancel.isVisible = true
views.voiceMessageTimerIndicator.isVisible = true
views.voiceMessageTimer.isVisible = true
views.voiceMessageSlideToCancel.alpha = 1f
views.voiceMessageTimerIndicator.alpha = 1f
views.voiceMessageTimer.alpha = 1f
views.voiceMessageSendButton.isVisible = false
}
fun hideRecordingViews(recordingState: RecordingUiState) {
// We need to animate the lock image first
if (recordingState != RecordingUiState.Locked) {
views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockImage.animate().translationY(0f).start()
views.voiceMessageLockBackground.isVisible = false
views.voiceMessageLockBackground.animate().translationY(0f).start()
} else {
animateLockImageWithBackground()
}
views.voiceMessageSlideToCancelDivider.isVisible = false
views.voiceMessageLockArrow.isVisible = false
views.voiceMessageLockArrow.animate().translationY(0f).start()
views.voiceMessageSlideToCancel.isVisible = false
views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
views.voiceMessagePlaybackLayout.isVisible = false
views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false
if (recordingState != RecordingUiState.Locked) {
views.voiceMessageMicButton
.animate()
.scaleX(1f)
.scaleY(1f)
.translationX(0f)
.translationY(0f)
.setDuration(150)
.withEndAction {
resetMicButtonUi()
}
.start()
} else {
views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false
views.voiceMessageMicButton.apply {
scaleX = 1f
scaleY = 1f
translationX = 0f
translationY = 0f
}
}
// Hide toasts if user cancelled recording before the timeout of the toast.
if (recordingState == RecordingUiState.Cancelled || recordingState == RecordingUiState.None) {
hideToast()
}
}
fun animateLockImageWithBackground() {
views.voiceMessageLockBackground.updateLayoutParams {
height = dimensionConverter.dpToPx(78)
}
views.voiceMessageLockBackground.apply {
animate()
.scaleX(0f)
.scaleY(0f)
.setDuration(400L)
.withEndAction {
updateLayoutParams {
height = dimensionConverter.dpToPx(180)
}
isVisible = false
scaleX = 1f
scaleY = 1f
animate().translationY(0f).start()
}
.start()
}
// Lock image animation
views.voiceMessageMicButton.isInvisible = true
views.voiceMessageLockImage.apply {
isVisible = true
animate()
.scaleX(0f)
.scaleY(0f)
.setDuration(400L)
.withEndAction {
isVisible = false
scaleX = 1f
scaleY = 1f
translationY = 0f
resetMicButtonUi()
}
.start()
}
}
fun resetMicButtonUi() {
views.voiceMessageMicButton.isVisible = true
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic)
views.voiceMessageMicButton.setAttributeBackground(android.R.attr.selectableItemBackgroundBorderless)
views.voiceMessageMicButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
if (rtlXMultiplier == -1) {
// RTL
setMargins(dimensionConverter.dpToPx(12), 0, 0, dimensionConverter.dpToPx(12))
} else {
setMargins(0, 0, dimensionConverter.dpToPx(12), dimensionConverter.dpToPx(12))
}
}
}
fun hideToast() {
views.voiceMessageToast.isVisible = false
}
fun showRecordingLockedViews(recordingState: RecordingUiState) {
hideRecordingViews(recordingState)
views.voiceMessagePlaybackLayout.isVisible = true
views.voiceMessagePlaybackTimerIndicator.isVisible = true
views.voicePlaybackControlButton.isVisible = false
views.voiceMessageSendButton.isVisible = true
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
renderToast(resources.getString(R.string.voice_message_tap_to_stop_toast))
}
fun showPlaybackViews() {
views.voiceMessagePlaybackTimerIndicator.isVisible = false
views.voicePlaybackControlButton.isVisible = true
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
fun initViews() {
hideRecordingViews(RecordingUiState.None)
views.voiceMessageMicButton.isVisible = true
views.voiceMessageSendButton.isVisible = false
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }
}
fun renderPlaying(state: VoiceMessagePlaybackTracker.Listener.State.Playing) {
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_pause_voice_message)
val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong())
views.voicePlaybackTime.text = formattedTimerText
}
fun renderIdle() {
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_play_voice_message)
}
fun renderToast(message: String) {
views.voiceMessageToast.removeCallbacks(hideToastRunnable)
views.voiceMessageToast.text = message
views.voiceMessageToast.isVisible = true
views.voiceMessageToast.postDelayed(hideToastRunnable, 2_000)
}
private val hideToastRunnable = Runnable {
views.voiceMessageToast.isVisible = false
}
fun renderRecordingTimer(recordingState: RecordingUiState, recordingTimeMillis: Long) {
val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis)
if (recordingState == RecordingUiState.Locked) {
views.voicePlaybackTime.apply {
post {
text = formattedTimerText
}
}
} else {
views.voiceMessageTimer.post {
views.voiceMessageTimer.text = formattedTimerText
}
}
}
fun renderRecordingWaveform(amplitudeList: Array<Int>) {
views.voicePlaybackWaveform.post {
views.voicePlaybackWaveform.apply {
amplitudeList.iterator().forEach {
update(it)
}
}
}
}
fun renderVisibilityChanged(parentChanged: Boolean, visibility: Int) {
if (parentChanged && visibility == ConstraintLayout.VISIBLE) {
views.voiceMessageMicButton.contentDescription = resources.getString(R.string.a11y_start_voice_message)
} else {
views.voiceMessageMicButton.contentDescription = ""
}
}
interface Actions {
fun onRequestRecording()
fun onMicButtonReleased()
fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState)
fun onSendVoiceMessage()
fun onDeleteVoiceMessage()
fun onWaveformClicked()
fun onVoicePlaybackButtonClicked()
}
}

View File

@ -212,7 +212,7 @@
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
<im.vector.app.features.home.room.detail.composer.VoiceMessageRecorderView
<im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
android:id="@+id/voiceMessageRecorderView"
android:layout_width="0dp"
android:layout_height="wrap_content"

View File

@ -95,6 +95,7 @@
<!-- Slide to cancel text should go under this view -->
<View
android:id="@+id/voiceMessageSlideToCancelDivider"
android:layout_width="48dp"
android:layout_height="0dp"
android:background="?android:colorBackground"
@ -135,11 +136,10 @@
android:id="@+id/voiceMessagePlaybackLayout"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="4dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/voiceMessageMicButton"
app:layout_constraintEnd_toStartOf="@id/voiceMessageSendButton"
app:layout_constraintStart_toStartOf="parent"
tools:layout_marginBottom="120dp"
tools:visibility="visible">