Add support for swipe actions to the message list screen

This commit is contained in:
cketti 2022-09-27 15:15:38 +02:00
parent 88dc1ac3cc
commit ac99032d3c
11 changed files with 412 additions and 12 deletions

View file

@ -0,0 +1,12 @@
package com.fsck.k9
enum class SwipeAction {
None,
ToggleSelection,
ToggleRead,
ToggleStar,
Archive,
Delete,
Spam,
Move
}

View file

@ -219,8 +219,8 @@ class MessageListAdapter internal constructor(
private fun getItem(position: Int): MessageListItem = messages[position]
fun getItemById(uniqueId: Long): MessageListItem {
return messagesMap[uniqueId]!!
fun getItemById(uniqueId: Long): MessageListItem? {
return messagesMap[uniqueId]
}
fun getItem(messageReference: MessageReference): MessageListItem? {
@ -524,7 +524,7 @@ class MessageListAdapter internal constructor(
private fun getItemFromView(view: View): MessageListItem {
val messageViewHolder = view.tag as MessageViewHolder
return getItemById(messageViewHolder.uniqueId)
return getItemById(messageViewHolder.uniqueId) ?: error("Couldn't find MessageListItem by View")
}
}

View file

@ -16,7 +16,7 @@ import android.widget.Toast
import androidx.appcompat.view.ActionMode
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@ -26,6 +26,7 @@ import com.fsck.k9.Account.SortType
import com.fsck.k9.Clock
import com.fsck.k9.K9
import com.fsck.k9.Preferences
import com.fsck.k9.SwipeAction
import com.fsck.k9.activity.FolderInfoHolder
import com.fsck.k9.activity.Search
import com.fsck.k9.activity.misc.ContactPicture
@ -236,9 +237,8 @@ class MessageListFragment :
val itemDecoration = MessageListItemDecoration(requireContext())
recyclerView.addItemDecoration(itemDecoration)
recyclerView.itemAnimator = DefaultItemAnimator().apply {
supportsChangeAnimations = false
}
recyclerView.itemAnimator = MessageListItemAnimator()
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
@ -252,8 +252,10 @@ class MessageListFragment :
}
private fun initializeMessageList() {
val theme = requireActivity().theme
adapter = MessageListAdapter(
theme = requireActivity().theme,
theme = theme,
res = resources,
layoutInflater = layoutInflater,
contactsPictureLoader = ContactPicture.getContactPictureLoader(),
@ -265,6 +267,19 @@ class MessageListFragment :
adapter.activeMessage = activeMessage
recyclerView.adapter = adapter
val itemTouchHelper = ItemTouchHelper(
MessageListSwipeCallback(
resources,
resourceProvider = SwipeResourceProvider(theme),
swipeActionSupportProvider,
swipeRightAction = SwipeAction.Archive,
swipeLeftAction = SwipeAction.ToggleRead,
adapter,
swipeListener
)
)
itemTouchHelper.attachToRecyclerView(recyclerView)
}
private fun initializeSortSettings() {
@ -1414,6 +1429,58 @@ class MessageListFragment :
private val isPullToRefreshAllowed: Boolean
get() = isRemoteSearchAllowed || isCheckMailAllowed
private val swipeListener = MessageListSwipeListener { item, action ->
when (action) {
SwipeAction.None -> Unit
SwipeAction.ToggleSelection -> {
toggleMessageSelect(item)
}
SwipeAction.ToggleRead -> {
setFlag(item, Flag.SEEN, !item.isRead)
}
SwipeAction.ToggleStar -> {
setFlag(item, Flag.FLAGGED, !item.isStarred)
}
SwipeAction.Archive -> {
onArchive(item.messageReference)
}
SwipeAction.Delete -> {
if (K9.isConfirmDelete) {
notifyItemChanged(item)
}
onDelete(listOf(item.messageReference))
}
SwipeAction.Spam -> {
if (K9.isConfirmSpam) {
notifyItemChanged(item)
}
onSpam(listOf(item.messageReference))
}
SwipeAction.Move -> {
notifyItemChanged(item)
onMove(item.messageReference)
}
}
}
private fun notifyItemChanged(item: MessageListItem) {
val position = adapter.getPosition(item) ?: return
adapter.notifyItemChanged(position)
}
private val swipeActionSupportProvider = SwipeActionSupportProvider { item, action ->
when (action) {
SwipeAction.None -> false
SwipeAction.ToggleSelection -> true
SwipeAction.ToggleRead -> !isOutbox
SwipeAction.ToggleStar -> !isOutbox
SwipeAction.Archive -> !isOutbox && item.account.hasArchiveFolder()
SwipeAction.Delete -> true
SwipeAction.Move -> !isOutbox && messagingController.isMoveCapable(item.account)
SwipeAction.Spam -> !isOutbox && item.account.hasSpamFolder() && item.folderId != item.account.spamFolderId
}
}
internal inner class MessageListActivityListener : SimpleMessagingListener() {
private val lock = Any()

View file

@ -0,0 +1,17 @@
package com.fsck.k9.ui.messagelist
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.RecyclerView.ViewHolder
class MessageListItemAnimator : DefaultItemAnimator() {
init {
supportsChangeAnimations = false
changeDuration = 120
}
override fun canReuseUpdatedViewHolder(viewHolder: ViewHolder, payloads: MutableList<Any>): Boolean {
// ItemTouchHelper expects swiped views to be removed from the view hierarchy. So we don't reuse views that are
// marked as having been swiped.
return !viewHolder.wasSwiped && super.canReuseUpdatedViewHolder(viewHolder, payloads)
}
}

View file

@ -0,0 +1,160 @@
package com.fsck.k9.ui.messagelist
import android.content.res.Resources
import android.graphics.Canvas
import android.graphics.Paint
import android.view.View
import androidx.core.graphics.withSave
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.fsck.k9.SwipeAction
import com.fsck.k9.ui.R
import kotlin.math.abs
class MessageListSwipeCallback(
resources: Resources,
private val resourceProvider: SwipeResourceProvider,
private val swipeActionSupportProvider: SwipeActionSupportProvider,
private val swipeRightAction: SwipeAction,
private val swipeLeftAction: SwipeAction,
private val adapter: MessageListAdapter,
private val listener: MessageListSwipeListener
) : ItemTouchHelper.Callback() {
private val iconPadding = resources.getDimension(R.dimen.messageListSwipeIconPadding).toInt()
private val backgroundColorPaint = Paint()
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: ViewHolder): Int {
if (viewHolder !is MessageViewHolder) return 0
val item = adapter.getItemById(viewHolder.uniqueId) ?: return 0
var swipeFlags = 0
if (swipeActionSupportProvider.isActionSupported(item, swipeRightAction)) {
swipeFlags = swipeFlags or ItemTouchHelper.RIGHT
}
if (swipeActionSupportProvider.isActionSupported(item, swipeLeftAction)) {
swipeFlags = swipeFlags or ItemTouchHelper.LEFT
}
return makeMovementFlags(0, swipeFlags)
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: ViewHolder,
target: ViewHolder
): Boolean {
throw UnsupportedOperationException("not implemented")
}
override fun onSwiped(viewHolder: ViewHolder, direction: Int) {
val holder = viewHolder as MessageViewHolder
val item = adapter.getItemById(holder.uniqueId) ?: error("Couldn't find MessageListItem")
// ItemTouchHelper expects swiped views to be removed from the view hierarchy. We mark this ViewHolder so that
// MessageListItemAnimator knows not to reuse it during an animation.
viewHolder.markAsSwiped(true)
when (direction) {
ItemTouchHelper.RIGHT -> listener.onSwipeAction(item, swipeRightAction)
ItemTouchHelper.LEFT -> listener.onSwipeAction(item, swipeLeftAction)
else -> error("Unsupported direction: $direction")
}
}
override fun clearView(recyclerView: RecyclerView, viewHolder: ViewHolder) {
super.clearView(recyclerView, viewHolder)
viewHolder.markAsSwiped(false)
}
override fun onChildDraw(
canvas: Canvas,
recyclerView: RecyclerView,
viewHolder: ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
canvas.withSave {
val view = viewHolder.itemView
val holder = viewHolder as MessageViewHolder
val item = adapter.getItemById(holder.uniqueId) ?: return@withSave
drawBackground(view, dX, item)
// Stop drawing the icon when the view has been animated all the way off the screen by ItemTouchHelper.
// We do this so the icon doesn't switch state when RecyclerView's ItemAnimator animates the view back after
// a toggle action (mark as read/unread, add/remove star) was used.
if (isCurrentlyActive || abs(dX).toInt() < view.width) {
drawIcon(dX, view, item)
}
}
super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
}
private fun Canvas.drawBackground(view: View, dX: Float, item: MessageListItem) {
val action = if (dX > 0) swipeRightAction else swipeLeftAction
backgroundColorPaint.color = resourceProvider.getBackgroundColor(item, action)
drawRect(
view.left.toFloat(),
view.top.toFloat(),
view.right.toFloat(),
view.bottom.toFloat(),
backgroundColorPaint
)
}
private fun Canvas.drawIcon(dX: Float, view: View, item: MessageListItem) {
if (dX > 0) {
drawSwipeRightIcon(view, item)
} else {
drawSwipeLeftIcon(view, item)
}
}
private fun Canvas.drawSwipeRightIcon(view: View, item: MessageListItem) {
resourceProvider.getIcon(item, swipeRightAction)?.let { icon ->
val iconLeft = iconPadding
val iconTop = view.top + ((view.height - icon.intrinsicHeight) / 2)
val iconRight = iconLeft + icon.intrinsicWidth
val iconBottom = iconTop + icon.intrinsicHeight
icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
icon.setTint(resourceProvider.getIconTint(item, swipeRightAction))
icon.draw(this)
}
}
private fun Canvas.drawSwipeLeftIcon(view: View, item: MessageListItem) {
resourceProvider.getIcon(item, swipeLeftAction)?.let { icon ->
val iconRight = view.right - iconPadding
val iconLeft = iconRight - icon.intrinsicWidth
val iconTop = view.top + ((view.height - icon.intrinsicHeight) / 2)
val iconBottom = iconTop + icon.intrinsicHeight
icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
icon.setTint(resourceProvider.getIconTint(item, swipeLeftAction))
icon.draw(this)
}
}
}
fun interface SwipeActionSupportProvider {
fun isActionSupported(item: MessageListItem, action: SwipeAction): Boolean
}
fun interface MessageListSwipeListener {
fun onSwipeAction(item: MessageListItem, action: SwipeAction)
}
private fun ViewHolder.markAsSwiped(value: Boolean) {
itemView.setTag(R.id.message_list_swipe_tag, if (value) true else null)
}
val ViewHolder.wasSwiped
get() = itemView.getTag(R.id.message_list_swipe_tag) == true

View file

@ -0,0 +1,66 @@
package com.fsck.k9.ui.messagelist
import android.content.res.Resources.Theme
import android.graphics.drawable.Drawable
import androidx.annotation.AttrRes
import com.fsck.k9.SwipeAction
import com.fsck.k9.ui.R
import com.fsck.k9.ui.resolveColorAttribute
import com.fsck.k9.ui.resolveDrawableAttribute
class SwipeResourceProvider(val theme: Theme) {
private val iconTint = theme.resolveColorAttribute(R.attr.messageListSwipeIconTint)
private val selectIcon = theme.loadDrawable(R.attr.messageListSwipeSelectIcon)
private val markAsReadIcon = theme.loadDrawable(R.attr.messageListSwipeMarkAsReadIcon)
private val markAsUnreadIcon = theme.loadDrawable(R.attr.messageListSwipeMarkAsUnreadIcon)
private val addStarIcon = theme.loadDrawable(R.attr.messageListSwipeAddStarIcon)
private val removeStarIcon = theme.loadDrawable(R.attr.messageListSwipeRemoveStarIcon)
private val archiveIcon = theme.loadDrawable(R.attr.messageListSwipeArchiveIcon)
private val deleteIcon = theme.loadDrawable(R.attr.messageListSwipeDeleteIcon)
private val spamIcon = theme.loadDrawable(R.attr.messageListSwipeSpamIcon)
private val moveIcon = theme.loadDrawable(R.attr.messageListSwipeMoveIcon)
private val noActionColor = theme.resolveColorAttribute(R.attr.messageListSwipeDisabledBackgroundColor)
private val selectColor = theme.resolveColorAttribute(R.attr.messageListSwipeSelectBackgroundColor)
private val markAsReadColor = theme.resolveColorAttribute(R.attr.messageListSwipeMarkAsReadBackgroundColor)
private val markAsUnreadColor = theme.resolveColorAttribute(R.attr.messageListSwipeMarkAsUnreadBackgroundColor)
private val addStarColor = theme.resolveColorAttribute(R.attr.messageListSwipeAddStarBackgroundColor)
private val removeStarColor = theme.resolveColorAttribute(R.attr.messageListSwipeRemoveStarBackgroundColor)
private val archiveColor = theme.resolveColorAttribute(R.attr.messageListSwipeArchiveBackgroundColor)
private val deleteColor = theme.resolveColorAttribute(R.attr.messageListSwipeDeleteBackgroundColor)
private val spamColor = theme.resolveColorAttribute(R.attr.messageListSwipeSpamBackgroundColor)
private val moveColor = theme.resolveColorAttribute(R.attr.messageListSwipeMoveBackgroundColor)
fun getIconTint(item: MessageListItem, action: SwipeAction): Int = iconTint
fun getIcon(item: MessageListItem, action: SwipeAction): Drawable? {
return when (action) {
SwipeAction.None -> null
SwipeAction.ToggleSelection -> selectIcon
SwipeAction.ToggleRead -> if (item.isRead) markAsUnreadIcon else markAsReadIcon
SwipeAction.ToggleStar -> if (item.isStarred) removeStarIcon else addStarIcon
SwipeAction.Archive -> archiveIcon
SwipeAction.Delete -> deleteIcon
SwipeAction.Spam -> spamIcon
SwipeAction.Move -> moveIcon
}
}
fun getBackgroundColor(item: MessageListItem, action: SwipeAction): Int {
return when (action) {
SwipeAction.None -> noActionColor
SwipeAction.ToggleSelection -> selectColor
SwipeAction.ToggleRead -> if (item.isRead) markAsUnreadColor else markAsReadColor
SwipeAction.ToggleStar -> if (item.isStarred) removeStarColor else addStarColor
SwipeAction.Archive -> archiveColor
SwipeAction.Delete -> deleteColor
SwipeAction.Spam -> spamColor
SwipeAction.Move -> moveColor
}
}
}
private fun Theme.loadDrawable(@AttrRes attributeId: Int): Drawable {
return resolveDrawableAttribute(attributeId).mutate()
}

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20,6h-8l-2,-2L4,4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,8c0,-1.1 -0.9,-2 -2,-2zM14,18v-3h-4v-4h4L14,8l5,5 -5,5z" />
</vector>

View file

@ -78,6 +78,26 @@
<attr name="messageListAnswered" format="reference"/>
<attr name="messageListForwarded" format="reference"/>
<attr name="messageListAnsweredForwarded" format="reference"/>
<attr name="messageListSwipeIconTint" format="reference|color"/>
<attr name="messageListSwipeDisabledBackgroundColor" format="reference|color"/>
<attr name="messageListSwipeSelectIcon" format="reference"/>
<attr name="messageListSwipeSelectBackgroundColor" format="reference|color"/>
<attr name="messageListSwipeMarkAsReadIcon" format="reference"/>
<attr name="messageListSwipeMarkAsReadBackgroundColor" format="reference|color"/>
<attr name="messageListSwipeMarkAsUnreadIcon" format="reference"/>
<attr name="messageListSwipeMarkAsUnreadBackgroundColor" format="reference|color"/>
<attr name="messageListSwipeAddStarIcon" format="reference"/>
<attr name="messageListSwipeAddStarBackgroundColor" format="reference|color"/>
<attr name="messageListSwipeRemoveStarIcon" format="reference"/>
<attr name="messageListSwipeRemoveStarBackgroundColor" format="reference|color"/>
<attr name="messageListSwipeArchiveIcon" format="reference"/>
<attr name="messageListSwipeArchiveBackgroundColor" format="reference|color"/>
<attr name="messageListSwipeDeleteIcon" format="reference"/>
<attr name="messageListSwipeDeleteBackgroundColor" format="reference|color"/>
<attr name="messageListSwipeSpamIcon" format="reference"/>
<attr name="messageListSwipeSpamBackgroundColor" format="reference|color"/>
<attr name="messageListSwipeMoveIcon" format="reference"/>
<attr name="messageListSwipeMoveBackgroundColor" format="reference|color"/>
<attr name="messageStar" format="reference"/>
<attr name="messageStarColor" format="color"/>
<attr name="messageViewBackgroundColor" format="reference|color"/>

View file

@ -9,4 +9,5 @@
<dimen name="account_setup_margin_between_items_incoming_and_outgoing">12dp</dimen>
<dimen name="message_view_pager_page_margin">16dp</dimen>
<dimen name="messageListSwipeIconPadding">24dp</dimen>
</resources>

View file

@ -19,4 +19,6 @@
<item type="id" name="settings_list_url_item"/>
<item type="id" name="drawer_list_folder_item"/>
<item type="id" name="message_list_swipe_tag"/>
</resources>

View file

@ -30,7 +30,7 @@
<item name="iconActionArchive">@drawable/ic_archive</item>
<item name="iconActionCompose">@drawable/ic_pencil</item>
<item name="iconActionDelete">@drawable/ic_trash_can</item>
<item name="iconActionMove">@drawable/ic_folder</item>
<item name="iconActionMove">@drawable/ic_move_to_folder</item>
<item name="iconActionCopy">@drawable/ic_content_copy</item>
<item name="iconActionNextStatus">@drawable/ic_chevron_right</item>
<item name="iconActionRefresh">@drawable/ic_refresh</item>
@ -91,6 +91,28 @@
<item name="messageListAnswered">@drawable/ic_messagelist_answered</item>
<item name="messageListForwarded">@drawable/ic_messagelist_forwarded</item>
<item name="messageListAnsweredForwarded">@drawable/ic_messagelist_answered_forwarded</item>
<item name="messageListSwipeIconTint">#ffffff</item>
<item name="messageListSwipeDisabledBackgroundColor">@color/material_gray_200</item>
<item name="messageListSwipeSelectIcon">@drawable/ic_import_status</item>
<item name="messageListSwipeSelectBackgroundColor">@color/material_blue_600</item>
<item name="messageListSwipeMarkAsReadIcon">?attr/iconActionMarkAsRead</item>
<item name="messageListSwipeMarkAsReadBackgroundColor">@color/material_blue_600</item>
<item name="messageListSwipeMarkAsUnreadIcon">?attr/iconActionMarkAsUnread</item>
<item name="messageListSwipeMarkAsUnreadBackgroundColor">@color/material_blue_600</item>
<item name="messageListSwipeAddStarIcon">?attr/iconActionFlag</item>
<item name="messageListSwipeAddStarBackgroundColor">@color/material_orange_600</item>
<item name="messageListSwipeRemoveStarIcon">?attr/iconActionUnflag</item>
<item name="messageListSwipeRemoveStarBackgroundColor">@color/material_orange_600</item>
<item name="messageListSwipeArchiveIcon">?attr/iconActionArchive</item>
<item name="messageListSwipeArchiveBackgroundColor">@color/material_green_600</item>
<item name="messageListSwipeDeleteIcon">?attr/iconActionDelete</item>
<item name="messageListSwipeDeleteBackgroundColor">@color/material_red_600</item>
<item name="messageListSwipeSpamIcon">?attr/iconActionSpam</item>
<item name="messageListSwipeSpamBackgroundColor">@color/material_red_700</item>
<item name="messageListSwipeMoveIcon">?attr/iconActionMove</item>
<item name="messageListSwipeMoveBackgroundColor">@color/material_purple_500</item>
<item name="messageStar">@drawable/btn_check_star</item>
<item name="messageStarColor">#fbbc04</item>
<item name="messageViewBackgroundColor">#ffffffff</item>
@ -129,6 +151,9 @@
<item name="behindRecyclerView">#F0F0F0</item>
</style>
<style name="Theme.K9.Light" parent="Theme.K9.Light.Common" />
<style name="Theme.K9.Dark" parent="Theme.K9.Dark.Common" />
<style name="Theme.K9.Dark.Common" parent="Theme.K9.Dark.Base">
<item name="android:navigationBarColor">#000000</item>
<item name="windowActionModeOverlay">true</item>
@ -212,6 +237,28 @@
<item name="messageListAnswered">@drawable/ic_messagelist_answered</item>
<item name="messageListForwarded">@drawable/ic_messagelist_forwarded</item>
<item name="messageListAnsweredForwarded">@drawable/ic_messagelist_answered_forwarded</item>
<item name="messageListSwipeIconTint">#ffffff</item>
<item name="messageListSwipeDisabledBackgroundColor">@color/material_gray_900</item>
<item name="messageListSwipeSelectIcon">@drawable/ic_import_status</item>
<item name="messageListSwipeSelectBackgroundColor">@color/material_blue_700</item>
<item name="messageListSwipeMarkAsReadIcon">?attr/iconActionMarkAsRead</item>
<item name="messageListSwipeMarkAsReadBackgroundColor">@color/material_blue_700</item>
<item name="messageListSwipeMarkAsUnreadIcon">?attr/iconActionMarkAsUnread</item>
<item name="messageListSwipeMarkAsUnreadBackgroundColor">@color/material_blue_700</item>
<item name="messageListSwipeAddStarIcon">?attr/iconActionFlag</item>
<item name="messageListSwipeAddStarBackgroundColor">@color/material_orange_700</item>
<item name="messageListSwipeRemoveStarIcon">?attr/iconActionUnflag</item>
<item name="messageListSwipeRemoveStarBackgroundColor">@color/material_orange_700</item>
<item name="messageListSwipeArchiveIcon">?attr/iconActionArchive</item>
<item name="messageListSwipeArchiveBackgroundColor">@color/material_green_700</item>
<item name="messageListSwipeDeleteIcon">?attr/iconActionDelete</item>
<item name="messageListSwipeDeleteBackgroundColor">@color/material_red_700</item>
<item name="messageListSwipeSpamIcon">?attr/iconActionSpam</item>
<item name="messageListSwipeSpamBackgroundColor">@color/material_red_800</item>
<item name="messageListSwipeMoveIcon">?attr/iconActionMove</item>
<item name="messageListSwipeMoveBackgroundColor">@color/material_purple_600</item>
<item name="messageStar">@drawable/btn_check_star</item>
<item name="messageStarColor">#fdd663</item>
<item name="messageViewBackgroundColor">#000000</item>
@ -251,9 +298,6 @@
<item name="behindRecyclerView">#202020</item>
</style>
<style name="Theme.K9.Light" parent="Theme.K9.Light.Common" />
<style name="Theme.K9.Dark" parent="Theme.K9.Dark.Common" />
<style name="Theme.K9.Dialog.Light" parent="Theme.K9.Light">
<item name="backgroundColorChooseAccountHeader">#cccccc</item>
</style>