Skip to content
This repository was archived by the owner on Nov 21, 2024. It is now read-only.

Commit 6168f4e

Browse files
committed
Add swipe to star function
Change-Id: I98d5cfec7b321ed1c623ac342dfc71e54f1a1f6e
1 parent 49d9ad0 commit 6168f4e

13 files changed

+473
-84
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package com.materialstudies.reply.ui.home
2+
3+
import android.animation.ValueAnimator
4+
import android.content.Context
5+
import android.graphics.Canvas
6+
import android.graphics.ColorFilter
7+
import android.graphics.Paint
8+
import android.graphics.PixelFormat
9+
import android.graphics.Rect
10+
import android.graphics.RectF
11+
import android.graphics.drawable.Drawable
12+
import androidx.annotation.ColorInt
13+
import androidx.core.content.ContextCompat
14+
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
15+
import com.materialstudies.reply.R
16+
import com.materialstudies.reply.util.getColorFromAttr
17+
import com.materialstudies.reply.util.lerp
18+
import com.materialstudies.reply.util.lerpArgb
19+
import kotlin.math.hypot
20+
21+
/**
22+
* A [Drawable] which handles drawing the background behind an email item preview and responds to
23+
* changes in the activated state. This includes:
24+
* - Drawing the star icon always
25+
* - On activation animating the star's color and scale
26+
* - On activation animating a colored circle background
27+
*/
28+
class EmailSwipeActionDrawable(context: Context) : Drawable() {
29+
30+
private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
31+
color = context.getColorFromAttr(R.attr.colorSecondary)
32+
style = Paint.Style.FILL
33+
}
34+
35+
// Rect to represent the circle used for the background/circular reveal animation.
36+
private val circle = RectF()
37+
private var cx = 0F
38+
private var cr = 0F
39+
40+
private val icon = ContextCompat.getDrawable(context, R.drawable.ic_twotone_star)!!
41+
private val iconMargin = context.resources.getDimension(R.dimen.keyline_6)
42+
private val iconIntrinsicWidth = icon?.intrinsicWidth ?: 0
43+
private val iconIntrinsicHeight = icon?.intrinsicHeight ?: 0
44+
45+
@ColorInt private val iconTint = context.getColorFromAttr(R.attr.colorOnBackground)
46+
@ColorInt private val iconTintActive = context.getColorFromAttr(R.attr.colorOnSecondary)
47+
48+
// Amount that we should 'overshoot' the icon's scale by when animating.
49+
private val iconMaxScaleAddition = 0.5F
50+
51+
private var progress = 0F
52+
set(value) {
53+
val constrained = value.coerceIn(0F, 1F)
54+
if (constrained != field) {
55+
field = constrained
56+
callback?.invalidateDrawable(this)
57+
}
58+
}
59+
private var progressAnim: ValueAnimator? = null
60+
private val interp = FastOutSlowInInterpolator()
61+
62+
override fun onBoundsChange(bounds: Rect?) {
63+
if (bounds == null) return
64+
update()
65+
}
66+
67+
fun update() {
68+
circle.set(
69+
bounds.left.toFloat(),
70+
bounds.top.toFloat(),
71+
bounds.right.toFloat(),
72+
bounds.bottom.toFloat()
73+
)
74+
75+
val sideToIconCenter = iconMargin + (iconIntrinsicWidth / 2F)
76+
cx = circle.left + iconMargin + (iconIntrinsicWidth / 2F)
77+
// Get the longest visible distance at which the circle will be displayed (the hypotenuse of
78+
// the triangle from the center of the icon, to the furthest side of the rect, to the top
79+
// corner of the rect.
80+
cr = hypot(circle.right - sideToIconCenter, (circle.height() / 2F))
81+
82+
callback?.invalidateDrawable(this)
83+
}
84+
85+
override fun isStateful(): Boolean = true
86+
87+
override fun onStateChange(state: IntArray?): Boolean {
88+
val initialProgress = progress
89+
val newProgress = if (state?.contains(android.R.attr.state_activated) == true) {
90+
1F
91+
} else {
92+
0F
93+
}
94+
progressAnim?.cancel()
95+
progressAnim = ValueAnimator.ofFloat(initialProgress, newProgress).apply {
96+
addUpdateListener {
97+
progress = animatedValue as Float
98+
}
99+
interpolator = interp
100+
duration = (Math.abs(newProgress - initialProgress) * 250F).toLong()
101+
}
102+
progressAnim?.start()
103+
return newProgress == initialProgress
104+
}
105+
106+
override fun draw(canvas: Canvas) {
107+
// Draw the circular reveal background.
108+
canvas.drawCircle(
109+
cx,
110+
circle.centerY(),
111+
cr * progress,
112+
circlePaint
113+
)
114+
115+
// Map our progress range from 0-1 to 0-PI
116+
val range = lerp(
117+
0F,
118+
Math.PI.toFloat(),
119+
progress
120+
)
121+
// Take the sin of our ranged progress * our maxScaleAddition as what we should
122+
// increase the icon's scale by.
123+
val additive = (Math.sin(range.toDouble()) * iconMaxScaleAddition)
124+
.coerceIn(0.0, 1.0)
125+
val scaleFactor = 1 + additive
126+
icon.setBounds(
127+
(cx - (iconIntrinsicWidth / 2F) * scaleFactor).toInt(),
128+
(circle.centerY() - (iconIntrinsicHeight / 2F) * scaleFactor).toInt(),
129+
(cx + (iconIntrinsicWidth / 2F) * scaleFactor).toInt(),
130+
(circle.centerY() + (iconIntrinsicHeight / 2F) * scaleFactor).toInt()
131+
)
132+
133+
// Draw/animate the color of the icon
134+
icon.setTint(
135+
lerpArgb(iconTint, iconTintActive, 0F, 0.15F, progress)
136+
)
137+
138+
// Draw the icon
139+
icon.draw(canvas)
140+
}
141+
142+
override fun setAlpha(alpha: Int) {
143+
circlePaint.alpha = alpha
144+
}
145+
146+
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
147+
148+
override fun setColorFilter(filter: ColorFilter?) {
149+
circlePaint.colorFilter = filter
150+
}
151+
152+
}
Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
package com.materialstudies.reply.ui.home
22

3-
import androidx.recyclerview.widget.LinearLayoutManager
3+
import android.view.View
44
import androidx.recyclerview.widget.RecyclerView
55
import com.google.android.material.shape.MaterialShapeDrawable
66
import com.materialstudies.reply.R
77
import com.materialstudies.reply.data.Email
88
import com.materialstudies.reply.databinding.EmailItemLayoutBinding
99
import com.materialstudies.reply.ui.common.EmailAttachmentAdapter
10-
import com.materialstudies.reply.util.ThemeUtils
1110
import com.materialstudies.reply.util.backgroundShapeDrawable
1211
import com.materialstudies.reply.util.foregroundShapeDrawable
12+
import com.materialstudies.reply.util.getStyleIdFromAttr
1313
import com.materialstudies.reply.util.setTextAppearanceCompat
1414

1515
class EmailViewHolder(
1616
private val binding: EmailItemLayoutBinding,
1717
listener: EmailAdapter.EmailAdapterListener
18-
): RecyclerView.ViewHolder(binding.root) {
18+
): RecyclerView.ViewHolder(binding.root), ReboundingSwipeActionCallback.ReboundableViewHolder {
1919

2020
private val attachmentAdapter = object : EmailAttachmentAdapter() {
2121
override fun getLayoutIdForPosition(position: Int): Int
@@ -25,24 +25,22 @@ class EmailViewHolder(
2525
private val cardBackground: MaterialShapeDrawable = binding.cardView.backgroundShapeDrawable
2626
private val cardForeground: MaterialShapeDrawable = binding.cardView.foregroundShapeDrawable
2727

28+
override val reboundableView: View = binding.cardView
29+
2830
init {
29-
binding.listener = listener
30-
binding.attachmentRecyclerView.apply {
31-
layoutManager = LinearLayoutManager(
32-
binding.root.context,
33-
LinearLayoutManager.HORIZONTAL,
34-
false
35-
)
36-
adapter = attachmentAdapter
31+
binding.run {
32+
this.listener = listener
33+
attachmentRecyclerView.adapter = attachmentAdapter
34+
root.background = EmailSwipeActionDrawable(root.context)
3735
}
3836
}
3937

4038
fun bind(email: Email) {
4139
binding.email = email
40+
binding.root.isActivated = email.isStarred
4241

4342
// Set the subject's TextAppearance
44-
val textAppearance = ThemeUtils.getResourceIdFromAttr(
45-
binding.subjectTextView.context,
43+
val textAppearance = binding.subjectTextView.context.getStyleIdFromAttr(
4644
if (email.isImportant) {
4745
R.attr.textAppearanceHeadline4
4846
} else {
@@ -60,10 +58,47 @@ class EmailViewHolder(
6058
// rounded or squared. Since all other corners are set to 0dp rounded, they are
6159
// not affected.
6260
val interpolation = if (email.isStarred) 1F else 0F
61+
setCardShapeInterpolation(interpolation)
62+
63+
binding.executePendingBindings()
64+
}
65+
66+
private fun setCardShapeInterpolation(interpolation: Float) {
6367
cardBackground.interpolation = interpolation
6468
cardForeground.interpolation = interpolation
69+
}
6570

66-
binding.executePendingBindings()
71+
72+
override fun onReboundOffsetChanged(
73+
currentSwipePercentage: Float,
74+
swipeThreshold: Float,
75+
currentTargetHasMetThresholdOnce: Boolean
76+
) {
77+
// Only alter shape and activation in the forward direction once the swipe
78+
// threshold has been met. Undoing the swipe would require releasing the item and
79+
// re-initiating the swipe.
80+
if (currentTargetHasMetThresholdOnce) return
81+
82+
val isStarred = binding.email?.isStarred ?: false
83+
84+
// Animate the top left corner radius of the email card as swipe happens.
85+
val interpolation = (currentSwipePercentage / swipeThreshold).coerceIn(0F, 1F)
86+
val adjustedInterpolation = Math.abs((if (isStarred) 1F else 0F) - interpolation)
87+
setCardShapeInterpolation(adjustedInterpolation)
88+
89+
// Start the background animation once the threshold is met.
90+
val thresholdMet = currentSwipePercentage >= swipeThreshold
91+
val shouldStar = when {
92+
thresholdMet && isStarred -> false
93+
thresholdMet && !isStarred -> true
94+
else -> return
95+
}
96+
binding.root.isActivated = shouldStar
97+
}
98+
99+
override fun onRebounded() {
100+
val email = binding.email ?: return
101+
binding.listener?.onEmailStarChanged(email, !email.isStarred)
67102
}
68103

69104
}

app/src/main/java/com/materialstudies/reply/ui/home/HomeFragment.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.fragment.app.Fragment
1010
import androidx.lifecycle.Observer
1111
import androidx.navigation.fragment.FragmentNavigatorExtras
1212
import androidx.navigation.fragment.findNavController
13+
import androidx.recyclerview.widget.ItemTouchHelper
1314
import com.materialstudies.reply.App
1415
import com.materialstudies.reply.R
1516
import com.materialstudies.reply.data.Email
@@ -52,6 +53,11 @@ class HomeFragment : Fragment(), EmailAdapter.EmailAdapterListener {
5253

5354
emailStore = (requireActivity().application as App).emailStore
5455

56+
binding.recyclerView.apply {
57+
val itemTouchHelper = ItemTouchHelper(ReboundingSwipeActionCallback())
58+
itemTouchHelper.attachToRecyclerView(this)
59+
adapter = emailAdapter
60+
}
5561
binding.recyclerView.adapter = emailAdapter
5662

5763
emailStore.emails.observe(this, Observer {

0 commit comments

Comments
 (0)