inverting and splitting the voice message view into logic and views
- creates a display entry point which will be called externally
This commit is contained in:
parent
bd0cd169c4
commit
f0ef9e9706
|
@ -138,7 +138,7 @@ 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.readreceipts.DisplayReadReceiptsBottomSheet
|
||||
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
|
||||
import im.vector.app.features.home.room.detail.timeline.action.EventSharedAction
|
||||
|
@ -692,8 +692,7 @@ 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)) {
|
||||
|
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* 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
|
||||
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingState
|
||||
import kotlin.math.abs
|
||||
|
||||
class DraggableStateProcessor(
|
||||
resources: Resources,
|
||||
dimensionConverter: DimensionConverter,
|
||||
) {
|
||||
|
||||
private val minimumMove = dimensionConverter.dpToPx(16)
|
||||
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 lastX: Float = 0f
|
||||
private var lastY: Float = 0f
|
||||
private var lastDistanceX: Float = 0f
|
||||
private var lastDistanceY: Float = 0f
|
||||
|
||||
fun reset(event: MotionEvent) {
|
||||
firstX = event.rawX
|
||||
firstY = event.rawY
|
||||
lastX = firstX
|
||||
lastY = firstY
|
||||
lastDistanceX = 0F
|
||||
lastDistanceY = 0F
|
||||
}
|
||||
|
||||
fun process(event: MotionEvent, recordingState: RecordingState): RecordingState {
|
||||
val currentX = event.rawX
|
||||
val currentY = event.rawY
|
||||
val distanceX = abs(firstX - currentX)
|
||||
val distanceY = abs(firstY - currentY)
|
||||
return nextRecordingState(recordingState, currentX, currentY, distanceX, distanceY).also {
|
||||
lastX = currentX
|
||||
lastY = currentY
|
||||
lastDistanceX = distanceX
|
||||
lastDistanceY = distanceY
|
||||
}
|
||||
}
|
||||
|
||||
private fun nextRecordingState(recordingState: RecordingState, currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): RecordingState {
|
||||
return when (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) {
|
||||
DraggingState.Cancelling(distanceX)
|
||||
} else if (currentY < firstY && distanceY > distanceX && distanceY > lastDistanceY) {
|
||||
DraggingState.Locking(distanceY)
|
||||
} else {
|
||||
recordingState
|
||||
}
|
||||
}
|
||||
is DraggingState.Cancelling -> {
|
||||
// Check if cancelling conditions met, also check if it should be initial state
|
||||
if (distanceX < minimumMove && distanceX < lastDistanceX) {
|
||||
RecordingState.Started
|
||||
} else if (shouldCancelRecording(distanceX)) {
|
||||
RecordingState.Cancelled
|
||||
} else {
|
||||
DraggingState.Cancelling(distanceX)
|
||||
}
|
||||
}
|
||||
is DraggingState.Locking -> {
|
||||
// Check if locking conditions met, also check if it should be initial state
|
||||
if (distanceY < minimumMove && distanceY < lastDistanceY) {
|
||||
RecordingState.Started
|
||||
} else if (shouldLockRecording(distanceY)) {
|
||||
RecordingState.Locked
|
||||
} else {
|
||||
DraggingState.Locking(distanceY)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
recordingState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldCancelRecording(distanceX: Float): Boolean {
|
||||
return distanceX >= distanceToCancel
|
||||
}
|
||||
|
||||
private fun shouldLockRecording(distanceY: Float): Boolean {
|
||||
return distanceY >= distanceToLock
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
/*
|
||||
* 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 org.matrix.android.sdk.api.extensions.orFalse
|
||||
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 {
|
||||
// Return true if the recording is started
|
||||
fun onVoiceRecordingStarted(): Boolean
|
||||
fun onVoiceRecordingEnded(isCancelled: Boolean)
|
||||
fun onVoiceRecordingPlaybackModeOn()
|
||||
fun onVoicePlaybackButtonClicked()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
var callback: Callback? = null
|
||||
|
||||
private var recordingState: RecordingState = RecordingState.None
|
||||
private var recordingTicker: CountUpTimer? = null
|
||||
|
||||
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
|
||||
)
|
||||
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::voiceMessageViews.isInitialized) return
|
||||
val parentChanged = changedView == this
|
||||
voiceMessageViews.renderVisibilityChanged(parentChanged, visibility)
|
||||
}
|
||||
|
||||
fun initVoiceRecordingViews() {
|
||||
recordingState = RecordingState.None
|
||||
stopRecordingTicker()
|
||||
voiceMessageViews.initViews(onVoiceRecordingEnded = {})
|
||||
}
|
||||
|
||||
private fun initListeners() {
|
||||
voiceMessageViews.start(object : VoiceMessageViews.Actions {
|
||||
override fun onRequestRecording() {
|
||||
if (callback?.onVoiceRecordingStarted().orFalse()) {
|
||||
display(RecordingState.Started)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRecordingStopped() {
|
||||
if (recordingState != RecordingState.Locked && recordingState != RecordingState.None) {
|
||||
display(RecordingState.None)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isActive() = recordingState != RecordingState.Cancelled
|
||||
|
||||
override fun updateState(updater: (RecordingState) -> RecordingState) {
|
||||
updater(recordingState).also {
|
||||
display(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendMessage() {
|
||||
display(RecordingState.None)
|
||||
}
|
||||
|
||||
override fun delete() {
|
||||
// this was previously marked as cancelled true
|
||||
display(RecordingState.None)
|
||||
}
|
||||
|
||||
override fun waveformClicked() {
|
||||
display(RecordingState.Playback)
|
||||
}
|
||||
|
||||
override fun onVoicePlaybackButtonClicked() {
|
||||
callback?.onVoicePlaybackButtonClicked()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun display(recordingState: RecordingState) {
|
||||
val previousState = this.recordingState
|
||||
val stateHasChanged = recordingState != this.recordingState
|
||||
this.recordingState = recordingState
|
||||
|
||||
if (stateHasChanged) {
|
||||
when (recordingState) {
|
||||
RecordingState.None -> {
|
||||
val isCancelled = previousState == RecordingState.Cancelled
|
||||
voiceMessageViews.hideRecordingViews(recordingState, isCancelled = isCancelled) { callback?.onVoiceRecordingEnded(it) }
|
||||
stopRecordingTicker()
|
||||
}
|
||||
RecordingState.Started -> {
|
||||
startRecordingTicker()
|
||||
voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast))
|
||||
voiceMessageViews.showRecordingViews()
|
||||
}
|
||||
RecordingState.Cancelled -> {
|
||||
voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback?.onVoiceRecordingEnded(it) }
|
||||
vibrate(context)
|
||||
}
|
||||
RecordingState.Locked -> {
|
||||
voiceMessageViews.renderLocked()
|
||||
postDelayed({
|
||||
voiceMessageViews.showRecordingLockedViews(recordingState) { callback?.onVoiceRecordingEnded(it) }
|
||||
}, 500)
|
||||
}
|
||||
RecordingState.Playback -> {
|
||||
stopRecordingTicker()
|
||||
voiceMessageViews.showPlaybackViews()
|
||||
callback?.onVoiceRecordingPlaybackModeOn()
|
||||
}
|
||||
is DraggingState -> when (recordingState) {
|
||||
is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(recordingState.distanceX)
|
||||
is DraggingState.Locking -> voiceMessageViews.renderLocking(recordingState.distanceY)
|
||||
}.exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
voiceMessageViews.renderRecordingTimer(recordingState, milliseconds / 1_000)
|
||||
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
|
||||
if (timeDiffToRecordingLimit <= 0) {
|
||||
post {
|
||||
display(RecordingState.Playback)
|
||||
}
|
||||
} else if (timeDiffToRecordingLimit in 10_000..10_999) {
|
||||
post {
|
||||
voiceMessageViews.renderToast(context.getString(R.string.voice_message_n_seconds_warning_toast, floor(timeDiffToRecordingLimit / 1000f).toInt()))
|
||||
vibrate(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecordingTicker() {
|
||||
recordingTicker?.stop()
|
||||
recordingTicker = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 -> {
|
||||
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 RecordingState {
|
||||
object None : RecordingState
|
||||
object Started : RecordingState
|
||||
object Cancelled : RecordingState
|
||||
object Locked : RecordingState
|
||||
object Playback : RecordingState
|
||||
}
|
||||
|
||||
sealed interface DraggingState : RecordingState {
|
||||
data class Cancelling(val distanceX: Float) : DraggingState
|
||||
data class Locking(val distanceY: Float) : DraggingState
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
/*
|
||||
* 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.RecordingState
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
|
||||
import org.matrix.android.sdk.api.extensions.orFalse
|
||||
|
||||
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.sendMessage()
|
||||
}
|
||||
|
||||
views.voiceMessageDeletePlayback.setOnClickListener {
|
||||
views.voiceMessageSendButton.isVisible = false
|
||||
actions.delete()
|
||||
}
|
||||
|
||||
views.voicePlaybackWaveform.setOnClickListener {
|
||||
actions.waveformClicked()
|
||||
}
|
||||
|
||||
views.voicePlaybackControlButton.setOnClickListener {
|
||||
actions.onVoicePlaybackButtonClicked()
|
||||
}
|
||||
observeMicButton(actions)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun observeMicButton(actions: Actions) {
|
||||
val positions = DraggableStateProcessor(resources, dimensionConverter)
|
||||
views.voiceMessageMicButton.setOnTouchListener { _, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
positions.reset(event)
|
||||
actions.onRequestRecording()
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
actions.onRecordingStopped()
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (actions.isActive()) {
|
||||
actions.updateState { currentState -> positions.process(event, currentState) }
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
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
|
||||
// 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: RecordingState, isCancelled: Boolean?, onVoiceRecordingEnded: (Boolean) -> Unit) {
|
||||
// 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 {
|
||||
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 {
|
||||
onVoiceRecordingEnded(it)
|
||||
}
|
||||
}
|
||||
|
||||
// Hide toasts if user cancelled recording before the timeout of the toast.
|
||||
if (recordingState == RecordingState.Cancelled || recordingState == RecordingState.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: RecordingState, onVoiceRecordingEnded: (Boolean) -> Unit) {
|
||||
hideRecordingViews(recordingState, null, onVoiceRecordingEnded)
|
||||
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(onVoiceRecordingEnded: (Boolean) -> Unit) {
|
||||
hideRecordingViews(RecordingState.None, null, onVoiceRecordingEnded)
|
||||
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: RecordingState, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 onRecordingStopped()
|
||||
fun isActive(): Boolean
|
||||
fun updateState(updater: (RecordingState) -> RecordingState)
|
||||
fun sendMessage()
|
||||
fun delete()
|
||||
fun waveformClicked()
|
||||
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"
|
||||
|
|
Loading…
Reference in New Issue