skip to Main Content

I have the following MotionLayout:

The Transition XML is as follows (I omitted the keyframes because they don’t matter for this example).

<Transition
    motion:constraintSetStart="@id/collapsed"
    motion:constraintSetEnd="@id/expanded"
    motion:duration="500"
    motion:motionInterpolator="cubic(0.2,0,0,1)">
    <OnSwipe
        motion:springDamping="63.973"
        motion:autoCompleteMode="spring"
        motion:onTouchUp="autoComplete"
        motion:dragDirection="dragUp"
        motion:touchAnchorId="@id/now_playing_touchable_area"
        motion:touchAnchorSide="top"
        motion:springStopThreshold="1.0E-4"
        motion:springMass="2.6"
        motion:springStiffness="389.76" />
    <KeyFrameSet>
        ...
    </KeyFrameSet>
</Transition>

When I start my app for the first time, and I begin a swipe, you can see that the layout tracks my finger correctly. It moves at the same speed as my finger, until I release, after which it uses spring physics to autocomplete to a start/end state.

However, I have also bound a click listener to transitionToStart() and transitionToEnd() (depending on whether we’re currently expanded/collapsed), and as soon as I trigger those once, the OnSwipe spring physics and tracking stop working. The layout no longer tracks my finger, but now uses the cubic motionInterpolator defined for the Transition.

How do I combat this? I would like to be able to use transitionToStart/End() programmatically, while still keeping spring physics for regular swipes.

EDIT: I have some more useful information. It turns out, after the first transitionToStart/End(), the OnSwipe still uses spring physics, except it seems they are being modified by the cubic motionInterpolator. How do I know this? If I set my motionInterpolator to linear, I experience no issues. Before and after a transitionToStart()/End(), the OnSwipe behavior works as intended and tracks my finger like it’s supposed to.

So a more specific question now is, how can I get the OnSwipe behavior to ignore the motionInterpolator and just always use linear (and why is it being affected by it in the first place)?

2

Answers


  1. Chosen as BEST ANSWER

    After a lot of messing around with it, I have found the solution.

    To restate what my goal is:

    • I want to be able use the OnSwipe feature of my MotionLayout's transition, such that it perfectly tracks my finger and uses spring physics when I release to snap to a starting/ending state.
    • I also want to be able to trigger transitionToStart/End programmatically (which in my case happens to be hooked up to a setOnClickListener on the collapsed bar, see the GIF in the original question).

    For OnSwipe to track my finger correctly, it needs the motionInterpolator to be linear, but I don't want to make the programmatic triggers to be linear as well, because it doesn't look very natural. I want them to be cubic instead, so this means I will need to switch between two transitions depending on whether it was triggered programmatically or by OnSwipe.

    So let's create two Transitions for this:

    <MotionScene
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:motion="http://schemas.android.com/apk/res-auto">
    
        <Transition
            android:id="@+id/programmatic_transition"
            motion:constraintSetStart="@id/collapsed"
            motion:constraintSetEnd="@id/expanded"
            motion:duration="500"
            motion:motionInterpolator="cubic(0.2,0,0,1)">
            <KeyFrameSet>
                ...
            </KeyFrameSet>
        </Transition>
    
        <Transition
            android:id="@+id/onswipe_transition"
            motion:constraintSetStart="@id/collapsed"
            motion:constraintSetEnd="@id/expanded"
            motion:duration="500"
            motion:motionInterpolator="linear">
            <OnSwipe
                motion:springDamping="60"
                motion:autoCompleteMode="spring"
                motion:onTouchUp="autoComplete"
                motion:dragDirection="dragUp"
                motion:touchAnchorId="@id/now_playing_anchor"
                motion:touchAnchorSide="top"
                motion:springStopThreshold="0"
                motion:springMass="2.6"
                motion:springStiffness="389.76" />
            <KeyFrameSet>
                ...
            </KeyFrameSet>
        </Transition>
    ...
    </MotionScene>
    

    Notice that both of them are identical transitions, both transitioning from the "collapsed" state to the "expanded" state, both with the same KeyFrameSet. The only differences are that one of them has a cubic motionInterpolator (I called this one "programmatic_transition"), and the other has a linear motionInterpolator paired with an OnSwipe tag with my desired spring physics (called "onswipe_transition").

    Now that we have our two transitions, we need to programmatically switch between them, depending on whether we're currently swiping, or just using transitionToStart/End().

    The latter is easy: just set the transition right before calling transitionToStart/End().

    fun collapse() {
        motionLayout.setTransition(R.id.programmatic_transition)
        motionLayout.transitionToStart()
    }
    fun expand() {
        motionLayout.setTransition(R.id.programmatic_transition)
        motionLayout.transitionToEnd()
    }
    

    For the swipes, we'll use a transition listener to detect when a swipe is started, so that we can set the "onswipe_transition" instead.

    motionLayout.setTransitionListener(object : TransitionListener {
        override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) {}
        override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) {
            if (dragging) {
                motionLayout.setTransition(R.id.onswipe_transition)
            }
        }
        override fun onTransitionChange(p0: MotionLayout, p1: Int, p2: Int, p3: Float) {}
        override fun onTransitionCompleted(p0: MotionLayout, p1: Int) {}
    })
    

    Notice how we only set the transition when our finger is dragging across the screen (this magical variable dragging). The last step now is to make sure dragging is true when our finger is dragging across the screen, and false when our finger has released the screen.

    For that, we'll override onInterceptTouchEvent on our MotionLayout so we can keep track of this.

    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        val isInTarget = // check if the position of the event is within the view that we want to allow to be dragged from
    
        return if (isInTarget || dragging) {
            when (ev.action) {
                MotionEvent.ACTION_DOWN -> {
                    dragging = true
                }
                MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                    dragging = false
                }
            }
            super.onInterceptTouchEvent(ev)
        } else {
            true
        }
    }
    

    This isInTarget value will depend on your own design.

    So there we have it. I have gone nearly crazy by figuring all of this out over the past two days, but I'm pleased to say that this solution has fixed the problem flawlessly. Hopefully I'll have helped someone else out there trying to something similar!


  2. This is a bug but even when it is fixed it would not do what you want.

    The basic problem is you want two transition between the two states.
    There is a work around that is not elegant.

    1. create to alias states collapsed2 and expanded2
    2. create transitions collapsed->expanded2 and expanded->collapsed2
    3. create auto transitions expanded2->expanded2 and collapsed->collapsed2

    This some what easy to do in the MotionEditor but it is messy

        <ConstraintSet
            android:id="@+id/collapsed2"
            motion:deriveConstraintsFrom="@+id/collapsed" />
        <ConstraintSet
            android:id="@+id/expanded2"
            motion:deriveConstraintsFrom="@+id/expanded" />
    
        <Transition
            android:id="@+id/click_to_expand"
            motion:constraintSetEnd="@+id/expanded2"
            motion:constraintSetStart="@id/collapsed"
            motion:motionInterpolator="cubic(0.2,0,0,1)"
             />
    
        <Transition
            android:id="@+id/click_to_collapse"
            motion:constraintSetEnd="@+id/collapsed2"
            motion:constraintSetStart="@id/expanded2"
            motion:motionInterpolator="cubic(0.2,0,0,1)"
            />
    
        <Transition
            motion:autoTransition="jumpToEnd"
            motion:constraintSetEnd="@+id/expanded"
            motion:constraintSetStart="@id/expanded2" />
        <Transition
            motion:autoTransition="jumpToEnd"
            motion:constraintSetEnd="@+id/collapsed"
            motion:constraintSetStart="@id/collapsed" />
    
    

    To transition

    
            if (progress < 0.5) {
                mMotionLayout.setTransition(R.id.click_to_expand);
                mMotionLayout.transitionToState(R.id.expanded2);
            }
            else {
                mMotionLayout.setTransition(R.id.click_to_collapse);
                mMotionLayout.transitionToState(R.id.collapsed2);
            }
    

    The power of this approach is you can customize the transitions with there own
    keyframes

    • interpolator
    • arcMode (motion:pathMotionArc="startVertical")
    • keyFrames
    • stagger

    Longer term I will be fixing the bug and adding a few features to make the use case simpler.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search