Search code examples
javaandroidandroid-studiokotlinmaterial-design

Animate Chip checking in Material Chipgroup (Android)


I'm writing an Android app where a user has to select an option using a ChipGroup and Chips. Everything is working fine, it's just a bit clunky as there is no animation except the default ripple when selecting a Chip.

I've read the Material Design 3 Docs and found this video showing a nice animation that I'd like to implement, but I don't know how.

I've tried:

  1. enabling

    android:animateLayoutChanges="true"
    

    but that only animates the adding and removing of a Chip, not the checking and unchecking.

  2. using

    TransitionManager.beginDelayedTransition(chipGroup);
    

    and that works fine on the chipGroup but the content of the Chip (tick appearing and text rescaling) does not animate.


Please tell me if I'm doing something wrong, here is also the method I use to add and select those Chips:

ChipAdapter adapter = new ChipAdapter(getContext());

    for(int i = 0; i < adapter.getCount(); i++){
        View chip = adapter.getView(i, chipGroup, chipGroup);
        if(chip instanceof Chip) {
            chip.setId(i);
            chip.setOnClickListener(v -> {
                for(int p = 0; p < chipGroup.getChildCount(); p++){
                    chipGroup.getChildAt(p).setSelected(false);
                }
                chip.setSelected(true);
            });
            chipGroup.addView(chip);
        }
    }

Solution

  • Update: Appended Jetpack Compose answer.

    With XML

    So as far as I can tell there is no embed way to simply enable this animation, but I found two ways to mimic the animation shown in your linked video.

    Results

    Option 1

    Gif showing the result of option 1

    Option 2

    Gif showing the result of option 2

    Code & Explaination

    Option 1

    This option works by enabling the animateLayoutChanges option of the ChipGroup that contains your chips

    android:animateLayoutChanges="true"
    

    and adding the following code for your ChipGroup:

    for (view in chipGroup.children) {
        val chip = view as Chip
        chip.setOnCheckedChangeListener { buttonView, _ ->
            val index = chipGroup.indexOfChild(buttonView)
            chipGroup.removeView(buttonView)
            chipGroup.addView(buttonView, index)
        }
    }
    

    This code will automatically remove the chip and instantly add it back to the ChipGroup whenever the selection state of a chip changes.

    Drawbacks

    • The animation is rather a transition of the form stateBefore -> invisible -> stateAfter than stateBefore -> stateAfter what results in the chip "flashing".

    Option 2

    For this option add the following custom Chip class (Kotlin) to your project and change your chips to be instances of CheckAnimationChip instead of com.google.android.material.chip.Chip:

    import android.animation.ObjectAnimator
    import android.content.Context
    import android.util.AttributeSet
    import androidx.core.animation.doOnEnd
    import com.google.android.material.chip.Chip
    import com.google.android.material.chip.ChipDrawable
    
    private const val CHIP_ICON_SIZE_PROPERTY_NAME = "chipIconSize"
    
    // A value of '0f' would be interpreted as 'use the default size' by the ChipDrawable, so use a slightly larger value.
    private const val INVISIBLE_CHIP_ICON_SIZE = 0.00001f
    
    /**
     * Custom Chip class which will animate transition between the [isChecked] states.
     */
    class CheckAnimationChip @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = com.google.android.material.R.attr.chipStyle
    ) : Chip(context, attrs, defStyleAttr) {
    
        private var onCheckedChangeListener: OnCheckedChangeListener? = null
        private var _chipDrawable: ChipDrawable
        private var defaultCheckedIconSize: Float
        private var currentlyScalingDown = false
    
        var animationDuration = 200L
    
        init {
            // Set default values for this category of chip.
            isCheckable = true
            isCheckedIconVisible = true
    
            _chipDrawable = chipDrawable as ChipDrawable
            defaultCheckedIconSize = _chipDrawable.chipIconSize
    
            super.setOnCheckedChangeListener { buttonView, isChecked ->
                if (currentlyScalingDown) {
                    // Block the changes caused by the scaling-down animation.
                    return@setOnCheckedChangeListener
                }
                onCheckedChangeListener?.onCheckedChanged(buttonView, isChecked)
    
                if (isChecked) {
                    scaleCheckedIconUp()
                } else if (!isChecked) {
                    scaleCheckedIconDown()
                }
            }
        }
    
        /**
         * Scale the size of the Checked-Icon from invisible to its default size.
         */
        private fun scaleCheckedIconUp() {
            ObjectAnimator.ofFloat(_chipDrawable, CHIP_ICON_SIZE_PROPERTY_NAME,
                INVISIBLE_CHIP_ICON_SIZE, defaultCheckedIconSize)
                .apply {
                    duration =  animationDuration
                    start()
                    doOnEnd {
                        _chipDrawable.chipIconSize = defaultCheckedIconSize
                    }
                }
        }
    
        /**
         * Scale the size of the Checked-Icon from its default size down to invisible. To achieve this, the
         * [isChecked] property needs to be manipulated. It is set to be true till the animation has ended.
         */
        private fun scaleCheckedIconDown() {
            currentlyScalingDown = true
            isChecked = true
            ObjectAnimator.ofFloat(_chipDrawable, CHIP_ICON_SIZE_PROPERTY_NAME,
                defaultCheckedIconSize, INVISIBLE_CHIP_ICON_SIZE)
                .apply {
                    duration =  animationDuration
                    start()
                    doOnEnd {
                        isChecked = false
                        currentlyScalingDown = false
                        _chipDrawable.chipIconSize = defaultCheckedIconSize
                    }
                }
        }
    
        override fun setOnCheckedChangeListener(listener: OnCheckedChangeListener?) {
            onCheckedChangeListener = listener
        }
    }
    

    This class changes the size of the chip's icon by using an ObjectAnimator. Therefore it accesses the ChipDrawable of the chip and changes the chipIconSize property with the animator.

    Drawbacks (rather picky)

    • This will only animate the icon size and not a complete transition between the drawables of the chip like in the linked video (e.g. there is no smooth transition of the border or background in this implementation).
    • You can observe a flickering of adjacent chips during the animation (see chip "Last 4 Weeks" in the gif), however I could only observe this problem on the emulator and did not notice it on a physical device.

    Jetpack Compose

    In Jetpack Compose you can make use of the animateConentSize() Modifier:

    FilterChip(
        selected = selected,
        onClick = { /* Handle Click */ },
        leadingIcon = {
            Box(
                Modifier.animateContentSize(keyframes { durationMillis = 200 })
            ) {
                if (selected) {
                    Icon(
                        imageVector = Icons.Default.Done,
                        contentDescription = null,
                        modifier = Modifier.size(FilterChipDefaults.IconSize)
                    )
                }
            }
        },
        label = { /* Text */ }
    )
    

    The important part here is to always have a composable (here the Box) for the leadingIcon that holds the check-icon if the chip is selected and is empty if not. This composable can then be animated smoothly with the animateContentSize() modifier.