This question was asked before in a too broad and unclear way here, so I've made it much more specific, with full explanation and code of what I've tried.
Background
I'm required to mimic the way the Google Calendar has a view at the top, that can animate and push down the view at the bottom, yet it has extra, different behavior. I've summarized what I'm trying to do on 3 characteristics:
- Pressing on the toolbar will always work, to toggle expanding/collapsing of the top view, while having an arrow icon that changes its rotation. This is like on Google Calendar app.
- The top view will always snap, just like on Google Calendar app.
- When the top view is collapsed, only pressing on the toolbar will allow to expand it. This is like on Google Calendar app
- When the top view is expanded, scrolling at the bottom view will only allow to collapse. If you try to scroll the other direction, nothing occurs, not even to the bottom view. This is like on Google Calendar app
- Once collapsed, the top view will be replaced with a smaller view. This means it will always take some space, above the bottom view. This is not like on Google Calendar app, because on the Calendar app, the top view completely disappears once you collapse it.
Here's how Google Calendar app look like:
Scrolling on the bottom view also slowly hides the view at the top:
The problem
Using various solutions I've found in the past, I've succeeded to implement only a part of the needed behavior:
Having some UI in the toolbar is done by having some views in it, including the arrow view. For manual expanding/collapsing I use
setExpanded
on theAppBarLayout
view. For the rotation of the arrow, I use a listener of how much theAppBarLayout
has resized, usingaddOnOffsetChangedListener
on it.Snapping is easily done by adding
snap
value intolayout_scrollFlags
attribute of theCollapsingToolbarLayout
. However, to make it really work well, without weird issues (reported here), I used this solution.Blocking of affecting the top view when scrolling can be done by using the same code I've used on #2 (here), by calling
setExpandEnabled
there. This works fine for when the top view is collapsed.Similar to #3, but sadly, since it uses
setNestedScrollingEnabled
, which is in both directions, this works well only when the top view is collapsed. When it's expanded, it still allows the bottom view to scroll up, as opposed to Calendar app. When expanded, I need it to only allow to collapse, without allowing to really scroll.
Here's a demonstration of the good, and the bad:
- This I've failed completely to do. I've tried a lot of solutions I've thought about, putting views in various places with various flags.
In short, I've succeeded doing 1-3, but not 4-5.
The code
Here's the current code (also available as whole project here) :
ScrollingActivity.kt
class ScrollingActivity : AppCompatActivity(), AppBarTracking {
private var mNestedView: MyRecyclerView? = null
private var mAppBarOffset: Int = 0
private var mAppBarIdle = false
private var mAppBarMaxOffset: Int = 0
private var isExpanded: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_scrolling)
val toolbar = findViewById<Toolbar>(R.id.toolbar)
setSupportActionBar(toolbar)
mNestedView = findViewById(R.id.nestedView)
app_bar.addOnOffsetChangedListener({ appBarLayout, verticalOffset ->
mAppBarOffset = verticalOffset
val totalScrollRange = appBarLayout.totalScrollRange
val progress = (-verticalOffset).toFloat() / totalScrollRange
arrowImageView.rotation = 180 + progress * 180
isExpanded = verticalOffset == 0;
mAppBarIdle = mAppBarOffset >= 0 || mAppBarOffset <= mAppBarMaxOffset
if (mAppBarIdle)
setExpandAndCollapseEnabled(isExpanded)
})
app_bar.post(Runnable { mAppBarMaxOffset = -app_bar.totalScrollRange })
mNestedView!!.setAppBarTracking(this)
mNestedView!!.layoutManager = LinearLayoutManager(this)
mNestedView!!.adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun getItemCount(): Int = 100
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return object : ViewHolder(LayoutInflater.from(parent.context).inflate(android.R.layout.simple_list_item_1, parent, false)) {}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
(holder.itemView.findViewById<View>(android.R.id.text1) as TextView).text = "item $position"
}
}
expandCollapseButton.setOnClickListener({ v ->
isExpanded = !isExpanded
app_bar.setExpanded(isExpanded, true)
})
}
private fun setExpandAndCollapseEnabled(enabled: Boolean) {
mNestedView!!.isNestedScrollingEnabled = enabled
}
override fun isAppBarExpanded(): Boolean = mAppBarOffset == 0
override fun isAppBarIdle(): Boolean = mAppBarIdle
}
MyRecyclerView.kt
/**A RecyclerView that allows temporary pausing of casuing its scroll to affect appBarLayout, based on https://stackoverflow.com/a/45338791/878126 */
class MyRecyclerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) : RecyclerView(context, attrs, defStyle) {
private var mAppBarTracking: AppBarTracking? = null
private var mView: View? = null
private var mTopPos: Int = 0
private var mLayoutManager: LinearLayoutManager? = null
interface AppBarTracking {
fun isAppBarIdle(): Boolean
fun isAppBarExpanded(): Boolean
}
override fun dispatchNestedPreScroll(dx: Int, dy: Int, consumed: IntArray?, offsetInWindow: IntArray?,
type: Int): Boolean {
if (type == ViewCompat.TYPE_NON_TOUCH && mAppBarTracking!!.isAppBarIdle()
&& isNestedScrollingEnabled) {
if (dy > 0) {
if (mAppBarTracking!!.isAppBarExpanded()) {
consumed!![1] = dy
return true
}
} else {
mTopPos = mLayoutManager!!.findFirstVisibleItemPosition()
if (mTopPos == 0) {
mView = mLayoutManager!!.findViewByPosition(mTopPos)
if (-mView!!.top + dy <= 0) {
consumed!![1] = dy - mView!!.top
return true
}
}
}
}
val returnValue = super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
if (offsetInWindow != null && !isNestedScrollingEnabled && offsetInWindow[1] != 0)
offsetInWindow[1] = 0
return returnValue
}
override fun setLayoutManager(layout: RecyclerView.LayoutManager) {
super.setLayoutManager(layout)
mLayoutManager = layoutManager as LinearLayoutManager
}
fun setAppBarTracking(appBarTracking: AppBarTracking) {
mAppBarTracking = appBarTracking
}
}
ScrollingCalendarBehavior.kt
class ScrollingCalendarBehavior(context: Context, attrs: AttributeSet) : AppBarLayout.Behavior(context, attrs) {
override fun onInterceptTouchEvent(parent: CoordinatorLayout?, child: AppBarLayout?, ev: MotionEvent): Boolean = false
}
activity_scrolling.xml
<android.support.design.widget.CoordinatorLayout
android:id="@+id/coordinatorLayout" 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" tools:context=".ScrollingActivity">
<android.support.design.widget.AppBarLayout
android:id="@+id/app_bar" android:layout_width="match_parent" android:layout_height="wrap_content"
android:fitsSystemWindows="true" android:stateListAnimator="@null" android:theme="@style/AppTheme.AppBarOverlay"
app:expanded="false" app:layout_behavior="com.example.user.expandingtopviewtest.ScrollingCalendarBehavior"
tools:targetApi="lollipop">
<android.support.design.widget.CollapsingToolbarLayout
android:id="@+id/collapsingToolbarLayout" android:layout_width="match_parent"
android:layout_height="match_parent" android:fitsSystemWindows="true"
android:minHeight="?attr/actionBarSize" app:contentScrim="?attr/colorPrimary"
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap" app:statusBarScrim="?attr/colorPrimaryDark">
<LinearLayout
android:layout_width="match_parent" android:layout_height="250dp"
android:layout_marginTop="?attr/actionBarSize" app:layout_collapseMode="parallax"
app:layout_collapseParallaxMultiplier="1.0">
<TextView
android:layout_width="match_parent" android:layout_height="match_parent" android:paddingLeft="10dp"
android:paddingRight="10dp" android:text="some large, expanded view"/>
</LinearLayout>
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" app:layout_collapseMode="pin"
app:popupTheme="@style/AppTheme.PopupOverlay">
<android.support.constraint.ConstraintLayout
android:id="@+id/expandCollapseButton" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" android:background="?android:selectableItemBackground"
android:clickable="true" android:focusable="true" android:orientation="vertical">
<TextView
android:id="@+id/titleTextView" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_marginBottom="8dp"
android:layout_marginLeft="8dp" android:layout_marginStart="8dp" android:ellipsize="end"
android:gravity="center" android:maxLines="1" android:text="title"
android:textAppearance="@style/TextAppearance.Widget.AppCompat.Toolbar.Title"
android:textColor="@android:color/white" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<ImageView
android:id="@+id/arrowImageView" android:layout_width="wrap_content" android:layout_height="0dp"
android:layout_marginLeft="8dp" android:layout_marginStart="8dp"
app:layout_constraintBottom_toBottomOf="@+id/titleTextView"
app:layout_constraintStart_toEndOf="@+id/titleTextView"
app:layout_constraintTop_toTopOf="@+id/titleTextView"
app:srcCompat="@android:drawable/arrow_down_float"
tools:ignore="ContentDescription,RtlHardcoded"/>
</android.support.constraint.ConstraintLayout>
</android.support.v7.widget.Toolbar>
</android.support.design.widget.CollapsingToolbarLayout>
</android.support.design.widget.AppBarLayout>
<com.example.user.expandingtopviewtest.MyRecyclerView
android:id="@+id/nestedView" android:layout_width="match_parent" android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" tools:context=".ScrollingActivity"/>
</android.support.design.widget.CoordinatorLayout>
The questions
How can I make the scrolling being blocked when the top view is expanded, yet allow to collapse while scrolling ?
How can I make the top view be replaced with a smaller one when collapsed (and back to large one when expanded), instead of completely disappear ?
Update
Even though I've got the basic of what I asked about, there are still 2 issues with the current code (available on Github, here) :
- The small view (the one you see on collapsed state) has inner views that need to have a clicking effect on them. When using the
android:background="?attr/selectableItemBackgroundBorderless"
on them, and clicking on this area while being expanded, the clicking is done on the small view. I've handled it by putting the small view on a different toolbar, but then the clicking effect doesn't get shown at all. I've written about this here, including sample project.
Here's the fix:
<android.support.design.widget.CoordinatorLayout
android:id="@+id/coordinatorLayout" 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" tools:context=".MainActivity">
<android.support.design.widget.AppBarLayout
android:id="@+id/app_bar" android:layout_width="match_parent" android:layout_height="wrap_content"
android:fitsSystemWindows="true" android:stateListAnimator="@null" android:theme="@style/AppTheme.AppBarOverlay"
app:expanded="false" app:layout_behavior="com.example.expandedtopviewtestupdate.ScrollingCalendarBehavior"
tools:targetApi="lollipop">
<android.support.design.widget.CollapsingToolbarLayout
android:id="@+id/collapsingToolbarLayout" android:layout_width="match_parent"
android:layout_height="match_parent" android:clipChildren="false" android:clipToPadding="false"
android:fitsSystemWindows="true" app:contentScrim="?attr/colorPrimary"
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap|enterAlways"
app:statusBarScrim="?attr/colorPrimaryDark">
<!--large view -->
<LinearLayout
android:id="@+id/largeView" android:layout_width="match_parent" android:layout_height="280dp"
android:layout_marginTop="?attr/actionBarSize" android:orientation="vertical"
app:layout_collapseMode="parallax" app:layout_collapseParallaxMultiplier="1.0">
<TextView
android:id="@+id/largeTextView" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_gravity="center"
android:background="?attr/selectableItemBackgroundBorderless" android:clickable="true"
android:focusable="true" android:focusableInTouchMode="false" android:gravity="center"
android:text="largeView" android:textSize="14dp" tools:background="?attr/colorPrimary"
tools:layout_gravity="top|center_horizontal" tools:layout_height="40dp" tools:layout_width="40dp"
tools:text="1"/>
</LinearLayout>
<!--top toolbar-->
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/small_view_height" app:contentInsetStart="0dp"
app:layout_collapseMode="pin" app:popupTheme="@style/AppTheme.PopupOverlay">
<android.support.constraint.ConstraintLayout
android:layout_width="match_parent" android:layout_height="wrap_content" android:clickable="true"
android:focusable="true">
<LinearLayout
android:id="@+id/expandCollapseButton" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?android:selectableItemBackground" android:gravity="center_vertical"
android:orientation="horizontal" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/titleTextView" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:ellipsize="end" android:gravity="center"
android:maxLines="1" android:text="title"
android:textAppearance="@style/TextAppearance.Widget.AppCompat.Toolbar.Title"
android:textColor="@android:color/white"/>
<ImageView
android:id="@+id/arrowImageView" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_marginLeft="8dp"
android:layout_marginStart="8dp" app:srcCompat="@android:drawable/arrow_up_float"
tools:ignore="ContentDescription,RtlHardcoded"/>
</LinearLayout>
</android.support.constraint.ConstraintLayout>
</android.support.v7.widget.Toolbar>
<android.support.v7.widget.Toolbar
android:id="@+id/smallLayoutContainer" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_marginTop="?attr/actionBarSize"
android:clipChildren="false" android:clipToPadding="false" app:contentInsetStart="0dp"
app:layout_collapseMode="pin">
<!--small view-->
<LinearLayout
android:id="@+id/smallLayout" android:layout_width="match_parent"
android:layout_height="@dimen/small_view_height" android:clipChildren="false"
android:clipToPadding="false" android:orientation="horizontal" tools:background="#ff330000"
tools:layout_height="@dimen/small_view_height">
<TextView
android:id="@+id/smallTextView" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_gravity="center"
android:background="?attr/selectableItemBackgroundBorderless" android:clickable="true"
android:focusable="true" android:focusableInTouchMode="false" android:gravity="center"
android:text="smallView" android:textSize="14dp" tools:background="?attr/colorPrimary"
tools:layout_gravity="top|center_horizontal" tools:layout_height="40dp"
tools:layout_width="40dp" tools:text="1"/>
</LinearLayout>
</android.support.v7.widget.Toolbar>
</android.support.design.widget.CollapsingToolbarLayout>
</android.support.design.widget.AppBarLayout>
<com.example.expandedtopviewtestupdate.MyRecyclerView
android:id="@+id/nestedView" android:layout_width="match_parent" android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" tools:context=".ScrollingActivity"/>
</android.support.design.widget.CoordinatorLayout>
- The Google Calendar allows to perform a scroll-down gesture on the toolbar itself, to trigger showing the month view. I've succeeded only adding a clicking event there, but not scrolling. Here's how it looks like: