11

I'm trying to implement this player animation

enter image description here

I also want to be able to both swipe on songs while collapsed and while expanded. So the idea was to use a MotionLayout with a RecyclerView, and also have each item of the RecyclerView be a MotionLayout. This way I could apply an expand animation on the RecyclerView and also apply transitions on it's children.

The transition itself works fine as seen in the attached video. But getting the drag to work on the RecyclerView itself doesn't. The drag is detected only if the touch starts from outside of the RecyclerView as shown in the highlighted touch in the video, where the touch starts from below the RecyclerView.

If the touch starts on the RecyclerView, the scrolling of songs consumes the event. Even disabling the scroll in the attached LinearLayoutManager doesn't work. I also tried overriding onTouch for the RecyclerView to always return false and not consume any touch events (in theory) but that also didn't work.

The project can be found here https://github.com/vlatkozelka/PlayerAnimation2 It's not meant to be a production ready application, just a testing playground.

Here is the relevant code

Layout:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/colorPrimary"
    app:layoutDescription="@xml/player_scene"
    tools:context=".MainActivity"
    android:id="@+id/layout_main"
    >

    <FrameLayout
        android:id="@+id/layout_player"
        android:layout_width="match_parent"
        android:layout_height="@dimen/mini_player_height"
        android:elevation="2dp"
        app:layout_constraintBottom_toTopOf="@id/layout_navigation"
        app:layout_constraintStart_toStartOf="parent"
        android:background="@color/dark_grey"
        android:focusable="true"
        android:clickable="true"
        >

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recycler_songs"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:focusable="false"
            android:clickable="false"
            />
    </FrameLayout>


    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/layout_navigation"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/dark_grey"
        android:padding="5dp"
        android:weightSum="3"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent">

        <ImageView
            android:id="@+id/iv_home"
            android:layout_width="0dp"
            android:layout_height="34dp"
            android:layout_weight="1"
            android:tint="#fff"
            app:layout_constraintEnd_toStartOf="@id/iv_search"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:srcCompat="@drawable/ic_home_24px" />

        <ImageView
            android:id="@+id/iv_search"
            android:layout_width="0dp"
            android:layout_height="34dp"
            android:layout_weight="1"
            android:tint="#fff"
            app:layout_constraintEnd_toStartOf="@id/iv_library"
            app:layout_constraintStart_toEndOf="@id/iv_home"
            app:layout_constraintTop_toTopOf="parent"
            app:srcCompat="@drawable/ic_search_24px" />

        <ImageView
            android:id="@+id/iv_library"
            android:layout_width="0dp"
            android:layout_height="34dp"
            android:layout_weight="1"
            android:tint="#fff"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toEndOf="@id/iv_search"
            app:layout_constraintTop_toTopOf="parent"
            app:srcCompat="@drawable/ic_library_music_24px" />


    </androidx.constraintlayout.widget.ConstraintLayout>



</androidx.constraintlayout.motion.widget.MotionLayout>

MotionScene:

<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <Transition
        android:id="@+id/dragUp"
        app:constraintSetEnd="@id/expanded"
        app:constraintSetStart="@id/collapsed">

        <OnSwipe
            app:dragDirection="dragUp"
            app:touchRegionId="@id/layout_player" />

        <OnClick
            app:clickAction="transitionToEnd"
            app:targetId="@id/layout_player" />

    </Transition>

    <Transition
        android:id="@+id/dragDown"
        app:constraintSetEnd="@id/collapsed"
        app:constraintSetStart="@id/expanded">


        <OnSwipe
            app:dragDirection="dragDown"
            app:touchRegionId="@id/layout_player" />

        <OnClick
            app:clickAction="transitionToEnd"
            app:targetId="@id/layout_player" />

    </Transition>

    <ConstraintSet android:id="@+id/collapsed">


        <Constraint
            android:id="@+id/layout_navigation"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/dark_grey"
            android:orientation="horizontal"
            android:padding="5dp"
            android:weightSum="3"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent" />

        <Constraint
            android:id="@+id/layout_player"
            android:layout_width="match_parent"
            android:layout_height="@dimen/mini_player_height"
            android:elevation="2dp"
            app:layout_constraintBottom_toTopOf="@id/layout_navigation"
            app:layout_constraintStart_toStartOf="parent" />

    </ConstraintSet>

    <ConstraintSet android:id="@+id/expanded">


        <Constraint
            android:id="@+id/layout_navigation"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:background="@color/dark_grey"
            android:orientation="horizontal"
            android:padding="5dp"
            android:weightSum="3"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="parent" />

        <Constraint
            android:id="@+id/layout_player"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:elevation="2dp"
            app:layout_constraintBottom_toTopOf="@id/layout_navigation"
            app:layout_constraintStart_toStartOf="parent" />


    </ConstraintSet>

</MotionScene>

MainActivity:

package com.example.playeranimation2

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import io.reactivex.subjects.PublishSubject
import org.notests.sharedsequence.Driver


data class AppState(
    val songs: List<Song> = Song.getRandomSongs(),
    val currentSong: Int = 0,
    val expandedPercent: Float = 0f
)

class MainActivity : AppCompatActivity() {

  companion object {
    var appState = AppState()
    val appStateObservable = PublishSubject.create<AppState>()
    val appStateDriver = Driver(appStateObservable.startWith(appState))
  }

  lateinit var mainLayout: MotionLayout
  lateinit var songsRecycler: RecyclerView
  lateinit var playerLayout : ViewGroup
  lateinit var adapter: SongsAdapter
  lateinit var snapHelper: PagerSnapHelper

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    mainLayout = findViewById(R.id.layout_main)
    songsRecycler = findViewById(R.id.recycler_songs)
    playerLayout = findViewById(R.id.layout_player)

    songsRecycler.layoutManager = LinearLayoutManager(this).apply { orientation = LinearLayoutManager.HORIZONTAL }

    adapter = SongsAdapter()

    songsRecycler.adapter = adapter

    adapter.refreshData(appState.songs)

    snapHelper = PagerSnapHelper()
    snapHelper.attachToRecyclerView(songsRecycler)




    mainLayout.setTransitionListener(object : MotionLayout.TransitionListener {
      override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) {

      }

      override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) {

      }

      override fun onTransitionChange(p0: MotionLayout?, p1: Int, p2: Int, p3: Float) {
        if (p1 == R.id.expanded) {
          appState = appState.copy(expandedPercent = 1f - p3)
        } else {
          appState = appState.copy(expandedPercent = p3)
        }

        emitNewAppState()
        adapter.expandedPercent = appState.expandedPercent

        updateAllRecyclerChildren()
      }

      override fun onTransitionCompleted(p0: MotionLayout?, p1: Int) {

      }

    })

    songsRecycler.addOnScrollListener(object:  RecyclerView.OnScrollListener(){
      override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        super.onScrolled(recyclerView, dx, dy)
        updateAllRecyclerChildren()
      }
    })
  }

  fun updateAllRecyclerChildren(){
    for (i in appState.songs.indices) {
      val childView = songsRecycler.getChildAt(i)
      if(childView != null){
        val songViewHolder = songsRecycler.getChildViewHolder(childView) as? SongsAdapter.SongViewHolder
        songViewHolder?.setExpandPercent(appState.expandedPercent)
      }
    }
  }

  fun emitNewAppState() {
    appStateObservable.onNext(appState)
  }

  class SongsAdapter : RecyclerView.Adapter<SongsAdapter.SongViewHolder>() {

    val data = arrayListOf<Song>()
    var expandedPercent : Float = 0f

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SongViewHolder {
      val view = LayoutInflater.from(parent.context).inflate(R.layout.item_song, parent, false)
      return SongViewHolder(view)
    }

    override fun getItemCount(): Int {
      return data.size
    }

    override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
      holder.bind(data[position], expandedPercent)
    }

    fun refreshData(data: List<Song>) {
      this.data.clear()
      this.data.addAll(data)
    }


    class SongViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
      var songImageView: ImageView? = itemView.findViewById(R.id.iv_cover_art)
      var songTitleView: TextView? = itemView.findViewById(R.id.tv_song_title)
      var rootView: MotionLayout? = itemView.findViewById(R.id.root_view)

      fun bind(song: Song, expandedPercent: Float) {
        songImageView?.setImageResource(song.imageRes)
        songTitleView?.text = song.title
        setExpandPercent(expandedPercent)
      }

      fun setExpandPercent(percent: Float) {
        rootView?.setInterpolatedProgress(percent)
      }

    }


  }

}

Any idea how I can get the RecyclerView to play nice with MotionLayout drag gesture?

vlatkozelka
  • 909
  • 1
  • 12
  • 27

3 Answers3

6

I faced the same problem and I was stuck for more than 5 days and finally, I found a simple solution that may fit for you. the problem is that the recycler view gets focused when the user touches the screen and did not forward it to the motion layout to apply the swipe animation.

So, simply I added a touch listener on the recycler view and forward it to the on touch method on the motion layout class. check the code

recyclerView.setOnTouchListener { _, event ->
            binding.motionLayout.onTouchEvent(event)
            return@setOnTouchListener false
        }

simply take the motion event from onTouchListener and forward it to the onTouchEvent method in motion layout

Hope that helped you ;)

Mina Samir
  • 1,527
  • 14
  • 15
  • hi, first of all thank you for this answer. I want to know have you experienced an issue where transition not working the first time, and only works after first ACTION_UP happened. – wiryadev Apr 09 '23 at 06:16
  • 1
    my case was not like this, but I think the core is to direct your touch to the correct view.... in my case, the problem was like this ... I have a recycler view that appears behind the main view ... and when the recycler view appears and touch it, the view did not get any updates ... so I add a touch listener and forward it as I want ... – Mina Samir Apr 10 '23 at 01:41
0

Created a pull request for a potential fix here

Two main changes needed

  1. Change from using touchRegionId to use both touchAnchorId and touchAnchorSide in the motion screen file
  <OnSwipe
     app:dragDirection="dragUp"
     app:touchAnchorId="@id/layout_player"
     app:touchAnchorSide="top"/>
  1. Delegate the RecyclerView's onTouch to the MotionLayout
songsRecycler.setOnTouchListener { _, motionEvent ->
        if (mainLayout.onTouchEvent(motionEvent).not()) {
            songsRecycler.onTouchEvent(motionEvent)
        } else {
            songsRecycler.onTouchEvent(motionEvent)
        }
    }
cyberman
  • 481
  • 4
  • 4
  • I tried this, but suddenly I got an error it couldn't find my LinearLayout container of the row that needs to slide in the MotionScene. Bummer. Looks like a great idea. – Mark Löwe Feb 20 '23 at 07:26
-1

The approach should be just the opposite. You should intercept the touch event in the parent view i.e. MotionLayout in this case. You should explicitly return true whenever your swipe is in the up or down direction, then this touch will not pass onto the child view i.e. RecyclerView. And if you return false it will be passed on to the recyclerview as usual. For more info check this resource. Sorry I was working 20 hours straight so I am in need of a nap. If you require the code, I can post it tomorrow.

Happy Coding!

Vanshaj Daga
  • 2,145
  • 11
  • 18
  • Yes please, if you can provide code whenever you can. Because I did try to return true on any touch event on the parent motion layout, and still the recycler view consumed the scroll. – vlatkozelka Oct 10 '20 at 08:43