From 9494174c3389edca915af23a2068cb1afa7002f4 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 17 Jul 2019 10:51:09 +0200 Subject: [PATCH] Swipe to reply in timeline (lab) --- CHANGES.md | 2 + .../home/room/detail/RoomDetailFragment.kt | 31 ++- .../detail/RoomMessageTouchHelperCallback.kt | 206 ++++++++++++++++++ .../features/settings/VectorPreferences.kt | 5 + vector/src/main/res/values/strings_riotX.xml | 1 + .../src/main/res/xml/vector_settings_labs.xml | 68 +++--- 6 files changed, 280 insertions(+), 33 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomMessageTouchHelperCallback.kt diff --git a/CHANGES.md b/CHANGES.md index ee6d2c8c06..f4c72ba7f1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,6 +7,8 @@ Features: Improvements: - Handle click on redacted events: view source and create permalink + - Improve long tap menu: reply on top, more compact (#368) + - Quick reply in timeline with swipe gesture Other changes: - diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 943b6165e0..4f91539c40 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -37,9 +37,11 @@ import androidx.annotation.DrawableRes import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import butterknife.BindView +import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.EpoxyVisibilityTracker import com.airbnb.mvrx.args import com.airbnb.mvrx.fragmentViewModel @@ -57,6 +59,7 @@ import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.message.* +import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent import im.vector.matrix.android.api.session.user.model.User @@ -87,7 +90,7 @@ import im.vector.riotx.features.home.room.detail.composer.TextComposerViewState import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.action.* import im.vector.riotx.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener -import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData +import im.vector.riotx.features.home.room.detail.timeline.item.* import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.html.PillImageSpan import im.vector.riotx.features.invite.VectorInviteView @@ -323,6 +326,32 @@ class RoomDetailFragment : }) recyclerView.setController(timelineEventController) timelineEventController.callback = this + + if (VectorPreferences.swipeToReplyIsEnabled(requireContext())) { + val swipeCallback = RoomMessageTouchHelperCallback(requireContext(), + R.drawable.ic_reply, + object : RoomMessageTouchHelperCallback.QuickReplayHandler { + override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { + (model as? AbsMessageItem)?.informationData?.let { + val eventId = it.eventId + roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId)) + } + } + + override fun canSwipeModelModel(model: EpoxyModel<*>): Boolean { + return when (model) { + is MessageFileItem, + is MessageImageVideoItem, + is MessageTextItem -> { + return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED + } + else -> false + } + } + }) + val touchHelper = ItemTouchHelper(swipeCallback) + touchHelper.attachToRecyclerView(recyclerView) + } } private fun setupComposer() { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomMessageTouchHelperCallback.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomMessageTouchHelperCallback.kt new file mode 100644 index 0000000000..d716d6b49c --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomMessageTouchHelperCallback.kt @@ -0,0 +1,206 @@ +/* + * Copyright 2019 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.riotx.features.home.room.detail + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.util.TypedValue +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_SWIPE +import androidx.recyclerview.widget.RecyclerView +import com.airbnb.epoxy.EpoxyModel +import com.airbnb.epoxy.EpoxyTouchHelperCallback +import com.airbnb.epoxy.EpoxyViewHolder +import timber.log.Timber + + +class RoomMessageTouchHelperCallback(private val context: Context, + @DrawableRes actionIcon: Int, + private val handler: QuickReplayHandler) : EpoxyTouchHelperCallback() { + + interface QuickReplayHandler { + fun performQuickReplyOnHolder(model: EpoxyModel<*>) + fun canSwipeModelModel(model: EpoxyModel<*>): Boolean + } + + private var swipeBack: Boolean = false + private var dX = 0f + private var startTracking = false + private var isVibrate = false + + private var replyButtonProgress: Float = 0F + private var lastReplyButtonAnimationTime: Long = 0 + + private var imageDrawable: Drawable = ContextCompat.getDrawable(context, actionIcon)!! + + + private val triggerDistance = convertToPx(100) + private val minShowDistance = convertToPx(20) + private val triggerDelta = convertToPx(20) + + override fun onSwiped(viewHolder: EpoxyViewHolder?, direction: Int) { + + } + + override fun onMove(recyclerView: RecyclerView?, viewHolder: EpoxyViewHolder?, target: EpoxyViewHolder?): Boolean { + return false + } + + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: EpoxyViewHolder): Int { + if (handler.canSwipeModelModel(viewHolder.model)) { + return ItemTouchHelper.Callback.makeMovementFlags(0, ItemTouchHelper.START) //Should we use Left? + } else { + return 0 + } + } + + + //We never let items completely go out + override fun convertToAbsoluteDirection(flags: Int, layoutDirection: Int): Int { + if (swipeBack) { + swipeBack = false; + return 0; + } + return super.convertToAbsoluteDirection(flags, layoutDirection); + } + + override fun onChildDraw(c: Canvas, recyclerView: RecyclerView, viewHolder: EpoxyViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean) { + if (actionState == ACTION_STATE_SWIPE) { + setTouchListener(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); + } + val size = triggerDistance + if (Math.abs(viewHolder.itemView.translationX) < size || dX > this.dX /*going back*/) { + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) + this.dX = dX + startTracking = true + } + drawReplyButton(c, viewHolder.itemView) + } + + + @SuppressLint("ClickableViewAccessibility") + private fun setTouchListener(c: Canvas, + recyclerView: RecyclerView, + viewHolder: EpoxyViewHolder, + dX: Float, dY: Float, + actionState: Int, isCurrentlyActive: Boolean) { + //TODO can this interfer with other interactions? should i remove it + recyclerView.setOnTouchListener { v, event -> + swipeBack = event.action == MotionEvent.ACTION_CANCEL || event.action == MotionEvent.ACTION_UP + if (swipeBack) { + if (Math.abs(dX) >= triggerDistance) { + try { + viewHolder.model?.let { handler.performQuickReplyOnHolder(it) } + } catch (e: IllegalStateException) { + Timber.e(e) + } + } + } + false + } + } + + + private fun drawReplyButton(canvas: Canvas, itemView: View) { + + Timber.v("drawReplyButton") + val translationX = Math.abs(itemView.translationX) + val newTime = System.currentTimeMillis() + val dt = Math.min(17, newTime - lastReplyButtonAnimationTime) + lastReplyButtonAnimationTime = newTime + val showing = translationX >= minShowDistance + if (showing) { + if (replyButtonProgress < 1.0f) { + replyButtonProgress += dt / 180.0f + if (replyButtonProgress > 1.0f) { + replyButtonProgress = 1.0f + } else { + itemView.invalidate() + } + } + } else if (translationX <= 0.0f) { + replyButtonProgress = 0f + startTracking = false + isVibrate = false + } else { + if (replyButtonProgress > 0.0f) { + replyButtonProgress -= dt / 180.0f + if (replyButtonProgress < 0.1f) { + replyButtonProgress = 0f + } else { + itemView.invalidate() + } + } + } + val alpha: Int + val scale: Float + if (showing) { + scale = if (replyButtonProgress <= 0.8f) { + 1.2f * (replyButtonProgress / 0.8f) + } else { + 1.2f - 0.2f * ((replyButtonProgress - 0.8f) / 0.2f) + } + alpha = Math.min(255f, 255 * (replyButtonProgress / 0.8f)).toInt() + } else { + scale = replyButtonProgress + alpha = Math.min(255f, 255 * replyButtonProgress).toInt() + } + + imageDrawable.alpha = alpha + if (startTracking) { + if (!isVibrate && translationX >= triggerDistance) { + itemView.performHapticFeedback( + HapticFeedbackConstants.LONG_PRESS +// , HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + ) + isVibrate = true + } + } + + val x: Int = itemView.width - if (translationX > triggerDistance + triggerDelta) { + (convertToPx(130) / 2).toInt() + } else { + (translationX / 2).toInt() + } + + val y = (itemView.top + itemView.measuredHeight / 2).toFloat() + //magic numbers? + imageDrawable.setBounds( + (x - convertToPx(12) * scale).toInt(), + (y - convertToPx(11) * scale).toInt(), + (x + convertToPx(12) * scale).toInt(), + (y + convertToPx(10) * scale).toInt() + ) + imageDrawable.draw(canvas) + imageDrawable.alpha = 255 + } + + private fun convertToPx(dp: Int): Float { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dp.toFloat(), + context.resources.displayMetrics + ) + } + +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt index 00f66f7c93..b607b5a6b6 100755 --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt @@ -148,6 +148,7 @@ object VectorPreferences { private const val SETTINGS_ENABLE_SEND_VOICE_FEATURE_PREFERENCE_KEY = "SETTINGS_ENABLE_SEND_VOICE_FEATURE_PREFERENCE_KEY" private const val SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY = "SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY" + private const val SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY = "SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY" // analytics const val SETTINGS_USE_ANALYTICS_KEY = "SETTINGS_USE_ANALYTICS_KEY" @@ -249,6 +250,10 @@ object VectorPreferences { return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY, false) } + fun swipeToReplyIsEnabled(context: Context): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY, true) + } + /** * Tells if we have already asked the user to disable battery optimisations on android >= M devices. * diff --git a/vector/src/main/res/values/strings_riotX.xml b/vector/src/main/res/values/strings_riotX.xml index 2b531bf4e0..7ad389d4b9 100644 --- a/vector/src/main/res/values/strings_riotX.xml +++ b/vector/src/main/res/values/strings_riotX.xml @@ -32,4 +32,5 @@ View the room directory + Enable swipe to reply in timeline \ No newline at end of file diff --git a/vector/src/main/res/xml/vector_settings_labs.xml b/vector/src/main/res/xml/vector_settings_labs.xml index 66eb3f393d..5620517216 100644 --- a/vector/src/main/res/xml/vector_settings_labs.xml +++ b/vector/src/main/res/xml/vector_settings_labs.xml @@ -1,45 +1,49 @@ - + - - + + - + - - - + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - + + + +