Merge pull request #4515 from vector-im/feature/adm/voice-state
Voice recording UI state in ViewModel
This commit is contained in:
commit
35f9bef94a
|
@ -0,0 +1 @@
|
|||
Voice recording mic button refactor with small animation tweaks in preparation for voice drafts
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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">
|
||||
|
|
Loading…
Reference in New Issue