Skip to content

Commit 1c456d1

Browse files
committed
ui: migrate motion to m3
1 parent 53d3937 commit 1c456d1

5 files changed

Lines changed: 515 additions & 227 deletions

File tree

app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt

Lines changed: 220 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -18,35 +18,37 @@
1818

1919
package org.oxycblt.auxio.home
2020

21-
import android.animation.Animator
22-
import android.animation.AnimatorListenerAdapter
23-
import android.animation.AnimatorSet
24-
import android.animation.ObjectAnimator
21+
import android.animation.ArgbEvaluator
22+
import android.graphics.Canvas
23+
import android.graphics.ColorFilter
24+
import android.graphics.PixelFormat
25+
import android.graphics.PorterDuff
26+
import android.graphics.Rect
27+
import android.graphics.drawable.Drawable
2528
import android.content.Context
2629
import android.content.res.ColorStateList
27-
import android.graphics.drawable.Drawable
28-
import android.graphics.drawable.RotateDrawable
2930
import android.os.Bundle
3031
import android.os.Parcelable
3132
import android.util.AttributeSet
32-
import android.util.Property
33-
import android.view.View
34-
import android.widget.ImageView
3533
import android.widget.TextView
3634
import androidx.annotation.AttrRes
3735
import androidx.annotation.FloatRange
3836
import androidx.core.os.BundleCompat
3937
import androidx.core.view.setMargins
4038
import androidx.core.view.updateLayoutParams
4139
import androidx.core.widget.TextViewCompat
40+
import androidx.dynamicanimation.animation.FloatValueHolder
41+
import androidx.dynamicanimation.animation.SpringAnimation
42+
import androidx.dynamicanimation.animation.SpringForce
43+
import com.google.android.material.R as MR
44+
import com.google.android.material.motion.MotionUtils
4245
import com.google.android.material.shape.MaterialShapeDrawable
4346
import com.leinardi.android.speeddial.FabWithLabelView
4447
import com.leinardi.android.speeddial.SpeedDialActionItem
4548
import com.leinardi.android.speeddial.SpeedDialView
4649
import kotlin.math.roundToInt
4750
import kotlinx.parcelize.Parcelize
4851
import org.oxycblt.auxio.R
49-
import org.oxycblt.auxio.ui.AnimConfig
5052
import org.oxycblt.auxio.util.getAttrColorCompat
5153
import org.oxycblt.auxio.util.getAttrResourceId
5254
import org.oxycblt.auxio.util.getDimen
@@ -65,9 +67,23 @@ import org.oxycblt.auxio.util.getDimenPixels
6567
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
6668
*/
6769
class ThemedSpeedDialView : SpeedDialView {
68-
private var mainFabAnimator: Animator? = null
70+
private var mainFabAnimation: MainFabAnimation? = null
6971
private val spacingSmall = context.getDimenPixels(R.dimen.spacing_small)
7072
private var innerChangeListener: ((Boolean) -> Unit)? = null
73+
private val mainFabDrawable = RotatingDrawable(checkNotNull(mainFab.drawable).mutate())
74+
private val mainFabSpatialSpring =
75+
MotionUtils.resolveThemeSpringForce(
76+
context,
77+
MR.attr.motionSpringFastSpatial,
78+
MR.style.Motion_Material3_Spring_Standard_Fast_Spatial,
79+
)
80+
private val mainFabEffectsSpring =
81+
MotionUtils.resolveThemeSpringForce(
82+
context,
83+
MR.attr.motionSpringFastEffects,
84+
MR.style.Motion_Material3_Spring_Standard_Fast_Effects,
85+
)
86+
private val argbEvaluator = ArgbEvaluator()
7187

7288
constructor(context: Context) : super(context)
7389

@@ -79,8 +95,6 @@ class ThemedSpeedDialView : SpeedDialView {
7995
@AttrRes defStyleAttr: Int,
8096
) : super(context, attrs, defStyleAttr)
8197

82-
private val stationaryConfig = AnimConfig.of(context, AnimConfig.STANDARD, AnimConfig.MEDIUM2)
83-
8498
init {
8599
// Work around ripple bug on Android 12 when useCompatPadding = true.
86100
// @see https://github.com/material-components/material-components-android/issues/2617
@@ -107,18 +121,15 @@ class ThemedSpeedDialView : SpeedDialView {
107121
.defaultColor
108122

109123
// Always use our own animation to fix the library issue that ripple is rotated as well.
110-
val mainFabDrawable =
111-
RotateDrawable().apply {
112-
drawable = mainFab.drawable
113-
toDegrees = 45f + 90f
114-
}
115124
mainFabAnimationRotateAngle = 0f
116125
setMainFabClosedDrawable(mainFabDrawable)
126+
setMainFabOpenedDrawable(null)
117127
setOnChangeListener(
118128
object : OnChangeListener {
119129
override fun onMainActionSelected(): Boolean = false
120130

121131
override fun onToggleChanged(isOpen: Boolean) {
132+
mainFabAnimation?.cancel()
122133
mainFab.backgroundTintList =
123134
ColorStateList.valueOf(
124135
if (isOpen) mainFabClosedBackgroundColor
@@ -128,67 +139,83 @@ class ThemedSpeedDialView : SpeedDialView {
128139
ColorStateList.valueOf(
129140
if (isOpen) mainFabClosedIconColor else mainFabOpenedIconColor
130141
)
131-
mainFabAnimator?.cancel()
132-
mainFabAnimator =
133-
createMainFabAnimator(isOpen).apply {
134-
addListener(
135-
object : AnimatorListenerAdapter() {
136-
override fun onAnimationEnd(animation: Animator) {
137-
mainFabAnimator = null
138-
}
139-
}
140-
)
141-
start()
142-
}
142+
mainFabAnimation = createMainFabAnimation(isOpen).apply { start() }
143143
innerChangeListener?.invoke(isOpen)
144144
}
145145
}
146146
)
147147
}
148148

149-
private fun createMainFabAnimator(isOpen: Boolean): Animator {
150-
val totalDuration = stationaryConfig.duration
151-
val partialDuration = totalDuration / 2 // This is half of the total duration
152-
val delay = totalDuration / 4 // This is one fourth of the total duration
149+
private fun createMainFabAnimation(isOpen: Boolean): MainFabAnimation {
150+
val targetBackgroundTint =
151+
if (isOpen) mainFabOpenedBackgroundColor else mainFabClosedBackgroundColor
152+
val targetImageTint = if (isOpen) mainFabOpenedIconColor else mainFabClosedIconColor
153153

154-
val backgroundTintAnimator =
155-
ObjectAnimator.ofArgb(
156-
mainFab,
157-
VIEW_PROPERTY_BACKGROUND_TINT,
158-
if (isOpen) mainFabOpenedBackgroundColor else mainFabClosedBackgroundColor,
159-
)
160-
.apply {
161-
startDelay = delay
162-
duration = partialDuration
163-
}
154+
val backgroundTintAnimation =
155+
createColorSpringAnimation(
156+
startColor =
157+
if (isOpen) mainFabClosedBackgroundColor else mainFabOpenedBackgroundColor,
158+
endColor = targetBackgroundTint,
159+
) { mainFab.backgroundTintList = ColorStateList.valueOf(it) }
160+
val imageTintAnimation =
161+
createColorSpringAnimation(
162+
startColor = if (isOpen) mainFabClosedIconColor else mainFabOpenedIconColor,
163+
endColor = targetImageTint,
164+
) { mainFab.imageTintList = ColorStateList.valueOf(it) }
165+
val rotationAnimation =
166+
createSpringAnimation(
167+
startValue = mainFabDrawable.rotationDegrees,
168+
finalValue = if (isOpen) MAIN_FAB_OPEN_ROTATION_DEGREES else 0f,
169+
springTemplate = mainFabSpatialSpring,
170+
minimumVisibleChange = MAIN_FAB_ROTATION_MIN_VISIBLE_CHANGE,
171+
dampingRatioOverride = MAIN_FAB_ROTATION_DAMPING_RATIO_OVERRIDE,
172+
) { mainFabDrawable.rotationDegrees = it }
164173

165-
val imageTintAnimator =
166-
ObjectAnimator.ofArgb(
167-
mainFab,
168-
IMAGE_VIEW_PROPERTY_IMAGE_TINT,
169-
if (isOpen) mainFabOpenedIconColor else mainFabClosedIconColor,
170-
)
171-
.apply {
172-
startDelay = delay
173-
duration = partialDuration
174-
}
174+
return MainFabAnimation(
175+
listOf(backgroundTintAnimation, imageTintAnimation, rotationAnimation)
176+
) {
177+
mainFabAnimation = null
178+
}
179+
}
175180

176-
val levelAnimator =
177-
ObjectAnimator.ofInt(
178-
mainFab.drawable,
179-
DRAWABLE_PROPERTY_LEVEL,
180-
if (isOpen) 10000 else 0,
181-
)
182-
.apply { duration = totalDuration }
181+
private fun createColorSpringAnimation(
182+
startColor: Int,
183+
endColor: Int,
184+
update: (Int) -> Unit,
185+
): SpringAnimation =
186+
createSpringAnimation(
187+
startValue = 0f,
188+
finalValue = 1f,
189+
springTemplate = mainFabEffectsSpring,
190+
minimumVisibleChange = MAIN_FAB_COLOR_PROGRESS_MIN_VISIBLE_CHANGE,
191+
) { progress ->
192+
val color =
193+
argbEvaluator.evaluate(progress.coerceIn(0f, 1f), startColor, endColor) as Int
194+
update(color)
195+
}
183196

184-
val animatorSet =
185-
AnimatorSet().apply {
186-
playTogether(backgroundTintAnimator, imageTintAnimator, levelAnimator)
187-
interpolator = stationaryConfig.interpolator
197+
private fun createSpringAnimation(
198+
startValue: Float,
199+
finalValue: Float,
200+
springTemplate: SpringForce,
201+
minimumVisibleChange: Float,
202+
dampingRatioOverride: Float? = null,
203+
update: (Float) -> Unit,
204+
): SpringAnimation =
205+
SpringAnimation(FloatValueHolder(startValue)).apply {
206+
spring =
207+
SpringForce().apply {
208+
dampingRatio = dampingRatioOverride ?: springTemplate.dampingRatio
209+
stiffness = springTemplate.stiffness
210+
finalPosition = finalValue
211+
}
212+
setStartValue(startValue)
213+
setMinimumVisibleChange(minimumVisibleChange)
214+
addUpdateListener { _, value, _ -> update(value) }
215+
addEndListener { _, canceled, value, _ ->
216+
update(if (canceled) value else finalValue)
188217
}
189-
animatorSet.start()
190-
return animatorSet
191-
}
218+
}
192219

193220
override fun onAttachedToWindow() {
194221
super.onAttachedToWindow()
@@ -304,34 +331,140 @@ class ThemedSpeedDialView : SpeedDialView {
304331
innerChangeListener = listener
305332
}
306333

307-
companion object {
308-
private val VIEW_PROPERTY_BACKGROUND_TINT =
309-
object : Property<View, Int>(Int::class.java, "backgroundTint") {
310-
override fun get(view: View): Int = view.backgroundTintList!!.defaultColor
334+
@Parcelize
335+
private class State(val superState: Parcelable?, val isOpen: Boolean) : Parcelable
336+
337+
private class MainFabAnimation(
338+
private val animations: List<SpringAnimation>,
339+
private val onEnd: () -> Unit,
340+
) {
341+
private var remainingAnimations = animations.size
342+
private var finished = false
311343

312-
override fun set(view: View, value: Int?) {
313-
view.backgroundTintList = ColorStateList.valueOf(value!!)
344+
init {
345+
animations.forEach { animation ->
346+
animation.addEndListener { _, _, _, _ ->
347+
if (finished) {
348+
return@addEndListener
349+
}
350+
remainingAnimations -= 1
351+
if (remainingAnimations == 0) {
352+
finished = true
353+
onEnd()
354+
}
314355
}
315356
}
357+
}
316358

317-
private val IMAGE_VIEW_PROPERTY_IMAGE_TINT =
318-
object : Property<ImageView, Int>(Int::class.java, "imageTint") {
319-
override fun get(view: ImageView): Int = view.imageTintList!!.defaultColor
359+
fun start() {
360+
animations.forEach { animation ->
361+
animation.animateToFinalPosition(animation.spring.finalPosition)
362+
}
363+
}
320364

321-
override fun set(view: ImageView, value: Int?) {
322-
view.imageTintList = ColorStateList.valueOf(value!!)
323-
}
365+
fun cancel() {
366+
if (finished) {
367+
return
324368
}
369+
finished = true
370+
animations.forEach { it.cancel() }
371+
onEnd()
372+
}
373+
}
374+
}
325375

326-
private val DRAWABLE_PROPERTY_LEVEL =
327-
object : Property<Drawable, Int>(Int::class.java, "level") {
328-
override fun get(drawable: Drawable): Int = drawable.level
376+
private class RotatingDrawable(
377+
drawable: Drawable,
378+
) : Drawable(), Drawable.Callback {
379+
private var wrappedDrawable = drawable
329380

330-
override fun set(drawable: Drawable, value: Int?) {
331-
drawable.level = value!!
332-
}
381+
var rotationDegrees = 0f
382+
set(value) {
383+
if (field == value) {
384+
return
333385
}
386+
field = value
387+
invalidateSelf()
388+
}
389+
390+
init {
391+
wrappedDrawable.callback = this
392+
}
393+
394+
override fun draw(canvas: Canvas) {
395+
if (bounds.isEmpty) {
396+
return
397+
}
398+
399+
val saveCount = canvas.save()
400+
canvas.rotate(rotationDegrees, bounds.exactCenterX(), bounds.exactCenterY())
401+
wrappedDrawable.draw(canvas)
402+
canvas.restoreToCount(saveCount)
403+
}
404+
405+
override fun onBoundsChange(bounds: Rect) {
406+
wrappedDrawable.bounds = bounds
407+
}
408+
409+
override fun onStateChange(state: IntArray): Boolean {
410+
val changed = wrappedDrawable.setState(state)
411+
if (changed) {
412+
invalidateSelf()
413+
}
414+
return changed
415+
}
416+
417+
override fun isStateful(): Boolean = wrappedDrawable.isStateful
418+
419+
override fun getPadding(padding: Rect): Boolean = wrappedDrawable.getPadding(padding)
420+
421+
override fun getIntrinsicWidth(): Int = wrappedDrawable.intrinsicWidth
422+
423+
override fun getIntrinsicHeight(): Int = wrappedDrawable.intrinsicHeight
424+
425+
override fun setAlpha(alpha: Int) {
426+
wrappedDrawable.alpha = alpha
427+
}
428+
429+
override fun getAlpha(): Int = wrappedDrawable.alpha
430+
431+
override fun setColorFilter(colorFilter: ColorFilter?) {
432+
wrappedDrawable.colorFilter = colorFilter
433+
}
434+
435+
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
436+
437+
override fun setTint(tintColor: Int) {
438+
wrappedDrawable.setTint(tintColor)
439+
}
440+
441+
override fun setTintList(tint: ColorStateList?) {
442+
wrappedDrawable.setTintList(tint)
443+
}
444+
445+
override fun setTintMode(tintMode: PorterDuff.Mode?) {
446+
wrappedDrawable.setTintMode(tintMode)
334447
}
335448

336-
@Parcelize private class State(val superState: Parcelable?, val isOpen: Boolean) : Parcelable
449+
override fun setVisible(visible: Boolean, restart: Boolean): Boolean =
450+
super.setVisible(visible, restart) || wrappedDrawable.setVisible(visible, restart)
451+
452+
override fun onLevelChange(level: Int): Boolean = wrappedDrawable.setLevel(level)
453+
454+
override fun invalidateDrawable(who: Drawable) {
455+
invalidateSelf()
456+
}
457+
458+
override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
459+
scheduleSelf(what, `when`)
460+
}
461+
462+
override fun unscheduleDrawable(who: Drawable, what: Runnable) {
463+
unscheduleSelf(what)
464+
}
337465
}
466+
467+
private const val MAIN_FAB_OPEN_ROTATION_DEGREES = 135f
468+
private const val MAIN_FAB_ROTATION_MIN_VISIBLE_CHANGE = 0.1f
469+
private const val MAIN_FAB_ROTATION_DAMPING_RATIO_OVERRIDE = 0.6f
470+
private const val MAIN_FAB_COLOR_PROGRESS_MIN_VISIBLE_CHANGE = 1f / 255f

0 commit comments

Comments
 (0)