Chris Banes 78054fe4ea AppBarLayout + WindowInset handling fixes
When AppBarLayout is used with a CollapsingToolbarLayout
child, it currently does not handle WindowInsets
very well. This commit fixes that by properly handling
insets.

The primary issue is when an AppBarLayout is used
with a single Toolbar (very common use case), where
the Toolbar will currently be displayed behind the status bar.

Another issue is with HeaderScrollingViewBehavior.
To workaround CoordinatorLayout using its 'compatible measuring'
for insets, the behavior manually sets the scrolling view to fit
system windows. This has other issues though because
that view will get padded in. Fixed by manually adding the insets
back into the measured dimensions.

Also updated the catalog layouts to make use of
android:fitsSystemWindows=true

Resolves https://github.com/material-components/material-components-android/pull/299

PiperOrigin-RevId: 238396626
2019-03-22 16:08:28 -04:00

1812 lines
66 KiB
Java

/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.material.appbar;
import com.google.android.material.R;
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Rect;
import android.os.Build;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.IdRes;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import com.google.android.material.animation.AnimationUtils;
import com.google.android.material.internal.ContextUtils;
import com.google.android.material.internal.ThemeEnforcement;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.math.MathUtils;
import androidx.core.util.ObjectsCompat;
import androidx.customview.view.AbsSavedState;
import androidx.core.view.NestedScrollingChild;
import androidx.core.view.ViewCompat;
import androidx.core.view.ViewCompat.NestedScrollType;
import androidx.core.view.WindowInsetsCompat;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Interpolator;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.ScrollView;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
/**
* AppBarLayout is a vertical {@link LinearLayout} which implements many of the features of material
* designs app bar concept, namely scrolling gestures.
*
* <p>Children should provide their desired scrolling behavior through {@link
* LayoutParams#setScrollFlags(int)} and the associated layout xml attribute: {@code
* app:layout_scrollFlags}.
*
* <p>This view depends heavily on being used as a direct child within a {@link CoordinatorLayout}.
* If you use AppBarLayout within a different {@link ViewGroup}, most of its functionality will not
* work.
*
* <p>AppBarLayout also requires a separate scrolling sibling in order to know when to scroll. The
* binding is done through the {@link ScrollingViewBehavior} behavior class, meaning that you should
* set your scrolling view's behavior to be an instance of {@link ScrollingViewBehavior}. A string
* resource containing the full class name is available.
*
* <pre>
* &lt;androidx.coordinatorlayout.widget.CoordinatorLayout
* xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
* xmlns:app=&quot;http://schemas.android.com/apk/res-auto&quot;
* android:layout_width=&quot;match_parent&quot;
* android:layout_height=&quot;match_parent&quot;&gt;
*
* &lt;androidx.core.widget.NestedScrollView
* android:layout_width=&quot;match_parent&quot;
* android:layout_height=&quot;match_parent&quot;
* app:layout_behavior=&quot;@string/appbar_scrolling_view_behavior&quot;&gt;
*
* &lt;!-- Your scrolling content --&gt;
*
* &lt;/androidx.core.widget.NestedScrollView&gt;
*
* &lt;com.google.android.material.appbar.AppBarLayout
* android:layout_height=&quot;wrap_content&quot;
* android:layout_width=&quot;match_parent&quot;&gt;
*
* &lt;androidx.appcompat.widget.Toolbar
* ...
* app:layout_scrollFlags=&quot;scroll|enterAlways&quot;/&gt;
*
* &lt;com.google.android.material.tabs.TabLayout
* ...
* app:layout_scrollFlags=&quot;scroll|enterAlways&quot;/&gt;
*
* &lt;/com.google.android.material.appbar.AppBarLayout&gt;
*
* &lt;/androidx.coordinatorlayout.widget.CoordinatorLayout&gt;
* </pre>
*
* @see <a href="http://www.google.com/design/spec/layout/structure.html#structure-app-bar">
* http://www.google.com/design/spec/layout/structure.html#structure-app-bar</a>
*/
@CoordinatorLayout.DefaultBehavior(AppBarLayout.Behavior.class)
public class AppBarLayout extends LinearLayout {
static final int PENDING_ACTION_NONE = 0x0;
static final int PENDING_ACTION_EXPANDED = 0x1;
static final int PENDING_ACTION_COLLAPSED = 0x2;
static final int PENDING_ACTION_ANIMATE_ENABLED = 0x4;
static final int PENDING_ACTION_FORCE = 0x8;
/**
* Interface definition for a callback to be invoked when an {@link AppBarLayout}'s vertical
* offset changes.
*/
// TODO: remove this base interface after the widget migration
public interface BaseOnOffsetChangedListener<T extends AppBarLayout> {
/**
* Called when the {@link AppBarLayout}'s layout offset has been changed. This allows child
* views to implement custom behavior based on the offset (for instance pinning a view at a
* certain y value).
*
* @param appBarLayout the {@link AppBarLayout} which offset has changed
* @param verticalOffset the vertical offset for the parent {@link AppBarLayout}, in px
*/
void onOffsetChanged(T appBarLayout, int verticalOffset);
}
/**
* Interface definition for a callback to be invoked when an {@link AppBarLayout}'s vertical
* offset changes.
*/
// TODO: update this interface after the widget migration
public interface OnOffsetChangedListener extends BaseOnOffsetChangedListener<AppBarLayout> {
void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset);
}
private static final int INVALID_SCROLL_RANGE = -1;
private int totalScrollRange = INVALID_SCROLL_RANGE;
private int downPreScrollRange = INVALID_SCROLL_RANGE;
private int downScrollRange = INVALID_SCROLL_RANGE;
private boolean haveChildWithInterpolator;
private int pendingAction = PENDING_ACTION_NONE;
private WindowInsetsCompat lastInsets;
private List<BaseOnOffsetChangedListener> listeners;
private boolean liftableOverride;
private boolean liftable;
private boolean lifted;
private boolean liftOnScroll;
@IdRes private int liftOnScrollTargetViewId;
@Nullable private WeakReference<View> liftOnScrollTargetView;
private int[] tmpStatesArray;
public AppBarLayout(Context context) {
this(context, null);
}
public AppBarLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public AppBarLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setOrientation(VERTICAL);
if (Build.VERSION.SDK_INT >= 21) {
// Use the bounds view outline provider so that we cast a shadow, even without a
// background
ViewUtilsLollipop.setBoundsViewOutlineProvider(this);
// If we're running on API 21+, we should reset any state list animator from our
// default style
ViewUtilsLollipop.setStateListAnimatorFromAttrs(
this, attrs, defStyleAttr, R.style.Widget_Design_AppBarLayout);
}
final TypedArray a =
ThemeEnforcement.obtainStyledAttributes(
context,
attrs,
R.styleable.AppBarLayout,
defStyleAttr,
R.style.Widget_Design_AppBarLayout);
ViewCompat.setBackground(this, a.getDrawable(R.styleable.AppBarLayout_android_background));
if (a.hasValue(R.styleable.AppBarLayout_expanded)) {
setExpanded(
a.getBoolean(R.styleable.AppBarLayout_expanded, false),
false, /* animate */
false /* force */);
}
if (Build.VERSION.SDK_INT >= 21 && a.hasValue(R.styleable.AppBarLayout_elevation)) {
ViewUtilsLollipop.setDefaultAppBarLayoutStateListAnimator(
this, a.getDimensionPixelSize(R.styleable.AppBarLayout_elevation, 0));
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// In O+, we have these values set in the style. Since there is no defStyleAttr for
// AppBarLayout at the AppCompat level, check for these attributes here.
if (a.hasValue(R.styleable.AppBarLayout_android_keyboardNavigationCluster)) {
this.setKeyboardNavigationCluster(
a.getBoolean(R.styleable.AppBarLayout_android_keyboardNavigationCluster, false));
}
if (a.hasValue(R.styleable.AppBarLayout_android_touchscreenBlocksFocus)) {
this.setTouchscreenBlocksFocus(
a.getBoolean(R.styleable.AppBarLayout_android_touchscreenBlocksFocus, false));
}
}
liftOnScroll = a.getBoolean(R.styleable.AppBarLayout_liftOnScroll, false);
liftOnScrollTargetViewId =
a.getResourceId(R.styleable.AppBarLayout_liftOnScrollTargetViewId, View.NO_ID);
a.recycle();
ViewCompat.setOnApplyWindowInsetsListener(
this,
new androidx.core.view.OnApplyWindowInsetsListener() {
@Override
public WindowInsetsCompat onApplyWindowInsets(View v, WindowInsetsCompat insets) {
return onWindowInsetChanged(insets);
}
});
}
/**
* Add a listener that will be called when the offset of this {@link AppBarLayout} changes.
*
* @param listener The listener that will be called when the offset changes.]
* @see #removeOnOffsetChangedListener(OnOffsetChangedListener)
*/
@SuppressWarnings("FunctionalInterfaceClash")
public void addOnOffsetChangedListener(BaseOnOffsetChangedListener listener) {
if (listeners == null) {
listeners = new ArrayList<>();
}
if (listener != null && !listeners.contains(listener)) {
listeners.add(listener);
}
}
@SuppressWarnings("FunctionalInterfaceClash")
public void addOnOffsetChangedListener(OnOffsetChangedListener listener) {
addOnOffsetChangedListener((BaseOnOffsetChangedListener) listener);
}
/**
* Remove the previously added {@link OnOffsetChangedListener}.
*
* @param listener the listener to remove.
*/
// TODO: change back to removeOnOffsetChangedListener once the widget migration is
// finished since the shim class needs to implement this method.
@SuppressWarnings("FunctionalInterfaceClash")
public void removeOnOffsetChangedListener(BaseOnOffsetChangedListener listener) {
if (listeners != null && listener != null) {
listeners.remove(listener);
}
}
@SuppressWarnings("FunctionalInterfaceClash")
public void removeOnOffsetChangedListener(OnOffsetChangedListener listener) {
removeOnOffsetChangedListener((BaseOnOffsetChangedListener) listener);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// If we're set to handle system windows but our first child is not, we need to add some
// height to ourselves to pad the first child down below the status bar
final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
if (heightMode != MeasureSpec.EXACTLY
&& ViewCompat.getFitsSystemWindows(this)
&& shouldOffsetFirstChild()) {
int newHeight = getMeasuredHeight();
switch (heightMode) {
case MeasureSpec.AT_MOST:
// For AT_MOST, we need to clamp our desired height with the max height
newHeight =
MathUtils.clamp(
getMeasuredHeight() + getTopInset(), 0, MeasureSpec.getSize(heightMeasureSpec));
break;
case MeasureSpec.UNSPECIFIED:
// For UNSPECIFIED we can use any height so just add the top inset
newHeight += getTopInset();
break;
default: // fall out
}
setMeasuredDimension(getMeasuredWidth(), newHeight);
}
invalidateScrollRanges();
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (ViewCompat.getFitsSystemWindows(this) && shouldOffsetFirstChild()) {
// If we need to offset the first child, we need to offset all of them to make space
final int topInset = getTopInset();
for (int z = getChildCount() - 1; z >= 0; z--) {
ViewCompat.offsetTopAndBottom(getChildAt(z), topInset);
}
}
invalidateScrollRanges();
haveChildWithInterpolator = false;
for (int i = 0, z = getChildCount(); i < z; i++) {
final View child = getChildAt(i);
final LayoutParams childLp = (LayoutParams) child.getLayoutParams();
final Interpolator interpolator = childLp.getScrollInterpolator();
if (interpolator != null) {
haveChildWithInterpolator = true;
break;
}
}
// If the user has set liftable manually, don't set liftable state automatically.
if (!liftableOverride) {
setLiftableState(liftOnScroll || hasCollapsibleChild());
}
}
private boolean hasCollapsibleChild() {
for (int i = 0, z = getChildCount(); i < z; i++) {
if (((LayoutParams) getChildAt(i).getLayoutParams()).isCollapsible()) {
return true;
}
}
return false;
}
private void invalidateScrollRanges() {
// Invalidate the scroll ranges
totalScrollRange = INVALID_SCROLL_RANGE;
downPreScrollRange = INVALID_SCROLL_RANGE;
downScrollRange = INVALID_SCROLL_RANGE;
}
@Override
public void setOrientation(int orientation) {
if (orientation != VERTICAL) {
throw new IllegalArgumentException(
"AppBarLayout is always vertical and does not support horizontal orientation");
}
super.setOrientation(orientation);
}
/**
* Sets whether this {@link AppBarLayout} is expanded or not, animating if it has already been
* laid out.
*
* <p>As with {@link AppBarLayout}'s scrolling, this method relies on this layout being a direct
* child of a {@link CoordinatorLayout}.
*
* @param expanded true if the layout should be fully expanded, false if it should be fully
* collapsed
* @attr ref com.google.android.material.R.styleable#AppBarLayout_expanded
*/
public void setExpanded(boolean expanded) {
setExpanded(expanded, ViewCompat.isLaidOut(this));
}
/**
* Sets whether this {@link AppBarLayout} is expanded or not.
*
* <p>As with {@link AppBarLayout}'s scrolling, this method relies on this layout being a direct
* child of a {@link CoordinatorLayout}.
*
* @param expanded true if the layout should be fully expanded, false if it should be fully
* collapsed
* @param animate Whether to animate to the new state
* @attr ref com.google.android.material.R.styleable#AppBarLayout_expanded
*/
public void setExpanded(boolean expanded, boolean animate) {
setExpanded(expanded, animate, true);
}
private void setExpanded(boolean expanded, boolean animate, boolean force) {
pendingAction =
(expanded ? PENDING_ACTION_EXPANDED : PENDING_ACTION_COLLAPSED)
| (animate ? PENDING_ACTION_ANIMATE_ENABLED : 0)
| (force ? PENDING_ACTION_FORCE : 0);
requestLayout();
}
@Override
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
return p instanceof LayoutParams;
}
@Override
protected LayoutParams generateDefaultLayoutParams() {
return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new LayoutParams(getContext(), attrs);
}
@Override
protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
if (Build.VERSION.SDK_INT >= 19 && p instanceof LinearLayout.LayoutParams) {
return new LayoutParams((LinearLayout.LayoutParams) p);
} else if (p instanceof MarginLayoutParams) {
return new LayoutParams((MarginLayoutParams) p);
}
return new LayoutParams(p);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
clearLiftOnScrollTargetView();
}
boolean hasChildWithInterpolator() {
return haveChildWithInterpolator;
}
/**
* Returns the scroll range of all children.
*
* @return the scroll range in px
*/
public final int getTotalScrollRange() {
if (totalScrollRange != INVALID_SCROLL_RANGE) {
return totalScrollRange;
}
int range = 0;
for (int i = 0, z = getChildCount(); i < z; i++) {
final View child = getChildAt(i);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
final int childHeight = child.getMeasuredHeight();
final int flags = lp.scrollFlags;
if ((flags & LayoutParams.SCROLL_FLAG_SCROLL) != 0) {
// We're set to scroll so add the child's height
range += childHeight + lp.topMargin + lp.bottomMargin;
if (i == 0 && ViewCompat.getFitsSystemWindows(child)) {
// If this is the first child and it wants to handle system windows, we need to make
// sure we don't scroll it past the inset
range -= getTopInset();
}
if ((flags & LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED) != 0) {
// For a collapsing scroll, we to take the collapsed height into account.
// We also break straight away since later views can't scroll beneath
// us
range -= ViewCompat.getMinimumHeight(child);
break;
}
} else {
// As soon as a view doesn't have the scroll flag, we end the range calculation.
// This is because views below can not scroll under a fixed view.
break;
}
}
return totalScrollRange = Math.max(0, range);
}
boolean hasScrollableChildren() {
return getTotalScrollRange() != 0;
}
/** Return the scroll range when scrolling up from a nested pre-scroll. */
int getUpNestedPreScrollRange() {
return getTotalScrollRange();
}
/** Return the scroll range when scrolling down from a nested pre-scroll. */
int getDownNestedPreScrollRange() {
if (downPreScrollRange != INVALID_SCROLL_RANGE) {
// If we already have a valid value, return it
return downPreScrollRange;
}
int range = 0;
for (int i = getChildCount() - 1; i >= 0; i--) {
final View child = getChildAt(i);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
final int childHeight = child.getMeasuredHeight();
final int flags = lp.scrollFlags;
if ((flags & LayoutParams.FLAG_QUICK_RETURN) == LayoutParams.FLAG_QUICK_RETURN) {
// First take the margin into account
int childRange = lp.topMargin + lp.bottomMargin;
// The view has the quick return flag combination...
if ((flags & LayoutParams.SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED) != 0) {
// If they're set to enter collapsed, use the minimum height
childRange += ViewCompat.getMinimumHeight(child);
} else if ((flags & LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED) != 0) {
// Only enter by the amount of the collapsed height
childRange += childHeight - ViewCompat.getMinimumHeight(child);
} else {
// Else use the full height
childRange += childHeight;
}
if (i == 0 && ViewCompat.getFitsSystemWindows(child)) {
// If this is the first child and it wants to handle system windows, we need to make
// sure we don't scroll past the inset
childRange = Math.min(childRange, childHeight - getTopInset());
}
range += childRange;
} else if (range > 0) {
// If we've hit an non-quick return scrollable view, and we've already hit a
// quick return view, return now
break;
}
}
return downPreScrollRange = Math.max(0, range);
}
/** Return the scroll range when scrolling down from a nested scroll. */
int getDownNestedScrollRange() {
if (downScrollRange != INVALID_SCROLL_RANGE) {
// If we already have a valid value, return it
return downScrollRange;
}
int range = 0;
for (int i = 0, z = getChildCount(); i < z; i++) {
final View child = getChildAt(i);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
int childHeight = child.getMeasuredHeight();
childHeight += lp.topMargin + lp.bottomMargin;
final int flags = lp.scrollFlags;
if ((flags & LayoutParams.SCROLL_FLAG_SCROLL) != 0) {
// We're set to scroll so add the child's height
range += childHeight;
if ((flags & LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED) != 0) {
// For a collapsing exit scroll, we to take the collapsed height into account.
// We also break the range straight away since later views can't scroll
// beneath us
range -= ViewCompat.getMinimumHeight(child);
break;
}
} else {
// As soon as a view doesn't have the scroll flag, we end the range calculation.
// This is because views below can not scroll under a fixed view.
break;
}
}
return downScrollRange = Math.max(0, range);
}
void dispatchOffsetUpdates(int offset) {
// Iterate backwards through the list so that most recently added listeners
// get the first chance to decide
if (listeners != null) {
for (int i = 0, z = listeners.size(); i < z; i++) {
final BaseOnOffsetChangedListener listener = listeners.get(i);
if (listener != null) {
listener.onOffsetChanged(this, offset);
}
}
}
}
public final int getMinimumHeightForVisibleOverlappingContent() {
final int topInset = getTopInset();
final int minHeight = ViewCompat.getMinimumHeight(this);
if (minHeight != 0) {
// If this layout has a min height, use it (doubled)
return (minHeight * 2) + topInset;
}
// Otherwise, we'll use twice the min height of our last child
final int childCount = getChildCount();
final int lastChildMinHeight =
childCount >= 1 ? ViewCompat.getMinimumHeight(getChildAt(childCount - 1)) : 0;
if (lastChildMinHeight != 0) {
return (lastChildMinHeight * 2) + topInset;
}
// If we reach here then we don't have a min height explicitly set. Instead we'll take a
// guess at 1/3 of our height being visible
return getHeight() / 3;
}
@Override
protected int[] onCreateDrawableState(int extraSpace) {
if (tmpStatesArray == null) {
// Note that we can't allocate this at the class level (in declaration) since some paths in
// super View constructor are going to call this method before that
tmpStatesArray = new int[4];
}
final int[] extraStates = tmpStatesArray;
final int[] states = super.onCreateDrawableState(extraSpace + extraStates.length);
extraStates[0] = liftable ? R.attr.state_liftable : -R.attr.state_liftable;
extraStates[1] = liftable && lifted ? R.attr.state_lifted : -R.attr.state_lifted;
// Note that state_collapsible and state_collapsed are deprecated. This is to keep compatibility
// with existing state list animators that depend on these states.
extraStates[2] = liftable ? R.attr.state_collapsible : -R.attr.state_collapsible;
extraStates[3] = liftable && lifted ? R.attr.state_collapsed : -R.attr.state_collapsed;
return mergeDrawableStates(states, extraStates);
}
/**
* Sets whether the {@link AppBarLayout} is liftable or not.
*
* @return true if the liftable state changed
*/
public boolean setLiftable(boolean liftable) {
this.liftableOverride = true;
return setLiftableState(liftable);
}
// Internal helper method that updates liftable state without enabling the override.
private boolean setLiftableState(boolean liftable) {
if (this.liftable != liftable) {
this.liftable = liftable;
refreshDrawableState();
return true;
}
return false;
}
/**
* Sets whether the {@link AppBarLayout} is in a lifted state or not.
*
* @return true if the lifted state changed
*/
public boolean setLifted(boolean lifted) {
return setLiftedState(lifted);
}
// Internal helper method that updates lifted state.
boolean setLiftedState(boolean lifted) {
if (this.lifted != lifted) {
this.lifted = lifted;
refreshDrawableState();
return true;
}
return false;
}
/**
* Sets whether the {@link AppBarLayout} lifts on scroll or not.
*
* <p>If set to true, the {@link AppBarLayout} will animate to the lifted, or elevated, state when
* content is scrolled beneath it. Requires
* `app:layout_behavior="@string/appbar_scrolling_view_behavior` to be set on the scrolling
* sibling (e.g., `NestedScrollView`, `RecyclerView`, etc.).
*/
public void setLiftOnScroll(boolean liftOnScroll) {
this.liftOnScroll = liftOnScroll;
}
/** Returns whether the {@link AppBarLayout} lifts on scroll or not. */
public boolean isLiftOnScroll() {
return liftOnScroll;
}
/**
* Sets the id of the view that the {@link AppBarLayout} should use to determine whether it should
* be lifted.
*/
public void setLiftOnScrollTargetViewId(@IdRes int liftOnScrollTargetViewId) {
this.liftOnScrollTargetViewId = liftOnScrollTargetViewId;
// Invalidate cached target view so it will be looked up on next scroll.
clearLiftOnScrollTargetView();
}
/**
* Returns the id of the view that the {@link AppBarLayout} should use to determine whether it
* should be lifted.
*/
@IdRes
public int getLiftOnScrollTargetViewId() {
return liftOnScrollTargetViewId;
}
boolean shouldLift(@Nullable View defaultScrollingView) {
View scrollingView = findLiftOnScrollTargetView();
if (scrollingView == null) {
scrollingView = defaultScrollingView;
}
return scrollingView != null
&& (scrollingView.canScrollVertically(-1) || scrollingView.getScrollY() > 0);
}
@Nullable
private View findLiftOnScrollTargetView() {
if (liftOnScrollTargetView == null && liftOnScrollTargetViewId != View.NO_ID) {
View targetView = null;
Activity activity = ContextUtils.getActivity(getContext());
if (activity != null) {
targetView = activity.findViewById(liftOnScrollTargetViewId);
} else if (getParent() instanceof ViewGroup) {
targetView = ((ViewGroup) getParent()).findViewById(liftOnScrollTargetViewId);
}
if (targetView != null) {
liftOnScrollTargetView = new WeakReference<>(targetView);
}
}
return liftOnScrollTargetView != null ? liftOnScrollTargetView.get() : null;
}
private void clearLiftOnScrollTargetView() {
if (liftOnScrollTargetView != null) {
liftOnScrollTargetView.clear();
}
liftOnScrollTargetView = null;
}
/**
* @deprecated target elevation is now deprecated. AppBarLayout's elevation is now controlled via
* a {@link android.animation.StateListAnimator}. If a target elevation is set, either by this
* method or the {@code app:elevation} attribute, a new state list animator is created which
* uses the given {@code elevation} value.
* @attr ref com.google.android.material.R.styleable#AppBarLayout_elevation
*/
@Deprecated
public void setTargetElevation(float elevation) {
if (Build.VERSION.SDK_INT >= 21) {
ViewUtilsLollipop.setDefaultAppBarLayoutStateListAnimator(this, elevation);
}
}
/**
* @deprecated target elevation is now deprecated. AppBarLayout's elevation is now controlled via
* a {@link android.animation.StateListAnimator}. This method now always returns 0.
*/
@Deprecated
public float getTargetElevation() {
return 0;
}
int getPendingAction() {
return pendingAction;
}
void resetPendingAction() {
pendingAction = PENDING_ACTION_NONE;
}
@VisibleForTesting
final int getTopInset() {
return lastInsets != null ? lastInsets.getSystemWindowInsetTop() : 0;
}
/**
* Whether the first child needs to be offset because it does not want to handle the top window
* inset
*/
private boolean shouldOffsetFirstChild() {
if (getChildCount() > 0) {
final View firstChild = getChildAt(0);
return firstChild.getVisibility() != GONE && !ViewCompat.getFitsSystemWindows(firstChild);
}
return false;
}
WindowInsetsCompat onWindowInsetChanged(final WindowInsetsCompat insets) {
WindowInsetsCompat newInsets = null;
if (ViewCompat.getFitsSystemWindows(this)) {
// If we're set to fit system windows, keep the insets
newInsets = insets;
}
// If our insets have changed, keep them and trigger a layout...
if (!ObjectsCompat.equals(lastInsets, newInsets)) {
lastInsets = newInsets;
requestLayout();
}
return insets;
}
/** A {@link ViewGroup.LayoutParams} implementation for {@link AppBarLayout}. */
public static class LayoutParams extends LinearLayout.LayoutParams {
/** @hide */
@RestrictTo(LIBRARY_GROUP)
@IntDef(
flag = true,
value = {
SCROLL_FLAG_NO_SCROLL,
SCROLL_FLAG_SCROLL,
SCROLL_FLAG_EXIT_UNTIL_COLLAPSED,
SCROLL_FLAG_ENTER_ALWAYS,
SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED,
SCROLL_FLAG_SNAP,
SCROLL_FLAG_SNAP_MARGINS,
})
@Retention(RetentionPolicy.SOURCE)
public @interface ScrollFlags {}
/**
* Disable scrolling on the view. This flag should not be combined with any of the other scroll
* flags.
*/
public static final int SCROLL_FLAG_NO_SCROLL = 0x0;
/**
* The view will be scroll in direct relation to scroll events. This flag needs to be set for
* any of the other flags to take effect. If any sibling views before this one do not have this
* flag, then this value has no effect.
*/
public static final int SCROLL_FLAG_SCROLL = 0x1;
/**
* When exiting (scrolling off screen) the view will be scrolled until it is 'collapsed'. The
* collapsed height is defined by the view's minimum height.
*
* @see ViewCompat#getMinimumHeight(View)
* @see View#setMinimumHeight(int)
*/
public static final int SCROLL_FLAG_EXIT_UNTIL_COLLAPSED = 0x2;
/**
* When entering (scrolling on screen) the view will scroll on any downwards scroll event,
* regardless of whether the scrolling view is also scrolling. This is commonly referred to as
* the 'quick return' pattern.
*/
public static final int SCROLL_FLAG_ENTER_ALWAYS = 0x4;
/**
* An additional flag for 'enterAlways' which modifies the returning view to only initially
* scroll back to it's collapsed height. Once the scrolling view has reached the end of it's
* scroll range, the remainder of this view will be scrolled into view. The collapsed height is
* defined by the view's minimum height.
*
* @see ViewCompat#getMinimumHeight(View)
* @see View#setMinimumHeight(int)
*/
public static final int SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED = 0x8;
/**
* Upon a scroll ending, if the view is only partially visible then it will be snapped and
* scrolled to its closest edge. For example, if the view only has its bottom 25% displayed, it
* will be scrolled off screen completely. Conversely, if its bottom 75% is visible then it will
* be scrolled fully into view.
*/
public static final int SCROLL_FLAG_SNAP = 0x10;
/**
* An additional flag to be used with 'snap'. If set, the view will be snapped to its top and
* bottom margins, as opposed to the edges of the view itself.
*/
public static final int SCROLL_FLAG_SNAP_MARGINS = 0x20;
/** Internal flags which allows quick checking features */
static final int FLAG_QUICK_RETURN = SCROLL_FLAG_SCROLL | SCROLL_FLAG_ENTER_ALWAYS;
static final int FLAG_SNAP = SCROLL_FLAG_SCROLL | SCROLL_FLAG_SNAP;
static final int COLLAPSIBLE_FLAGS =
SCROLL_FLAG_EXIT_UNTIL_COLLAPSED | SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED;
int scrollFlags = SCROLL_FLAG_SCROLL;
Interpolator scrollInterpolator;
public LayoutParams(Context c, AttributeSet attrs) {
super(c, attrs);
TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.AppBarLayout_Layout);
scrollFlags = a.getInt(R.styleable.AppBarLayout_Layout_layout_scrollFlags, 0);
if (a.hasValue(R.styleable.AppBarLayout_Layout_layout_scrollInterpolator)) {
int resId = a.getResourceId(R.styleable.AppBarLayout_Layout_layout_scrollInterpolator, 0);
scrollInterpolator = android.view.animation.AnimationUtils.loadInterpolator(c, resId);
}
a.recycle();
}
public LayoutParams(int width, int height) {
super(width, height);
}
public LayoutParams(int width, int height, float weight) {
super(width, height, weight);
}
public LayoutParams(ViewGroup.LayoutParams p) {
super(p);
}
public LayoutParams(MarginLayoutParams source) {
super(source);
}
@RequiresApi(19)
public LayoutParams(LinearLayout.LayoutParams source) {
// The copy constructor called here only exists on API 19+.
super(source);
}
@RequiresApi(19)
public LayoutParams(LayoutParams source) {
// The copy constructor called here only exists on API 19+.
super(source);
scrollFlags = source.scrollFlags;
scrollInterpolator = source.scrollInterpolator;
}
/**
* Set the scrolling flags.
*
* @param flags bitwise int of {@link #SCROLL_FLAG_SCROLL}, {@link
* #SCROLL_FLAG_EXIT_UNTIL_COLLAPSED}, {@link #SCROLL_FLAG_ENTER_ALWAYS}, {@link
* #SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED}, {@link #SCROLL_FLAG_SNAP}, and {@link
* #SCROLL_FLAG_SNAP_MARGINS}. Otherwise, use {@link #SCROLL_FLAG_NO_SCROLL} to disable
* scrolling.
* @see #getScrollFlags()
* @attr ref com.google.android.material.R.styleable#AppBarLayout_Layout_layout_scrollFlags
*/
public void setScrollFlags(@ScrollFlags int flags) {
scrollFlags = flags;
}
/**
* Returns the scrolling flags.
*
* @see #setScrollFlags(int)
* @attr ref com.google.android.material.R.styleable#AppBarLayout_Layout_layout_scrollFlags
*/
@ScrollFlags
public int getScrollFlags() {
return scrollFlags;
}
/**
* Set the interpolator to when scrolling the view associated with this {@link LayoutParams}.
*
* @param interpolator the interpolator to use, or null to use normal 1-to-1 scrolling.
* @attr ref com.google.android.material.R.styleable#AppBarLayout_Layout_layout_scrollInterpolator
* @see #getScrollInterpolator()
*/
public void setScrollInterpolator(Interpolator interpolator) {
scrollInterpolator = interpolator;
}
/**
* Returns the {@link Interpolator} being used for scrolling the view associated with this
* {@link LayoutParams}. Null indicates 'normal' 1-to-1 scrolling.
*
* @attr ref com.google.android.material.R.styleable#AppBarLayout_Layout_layout_scrollInterpolator
* @see #setScrollInterpolator(Interpolator)
*/
public Interpolator getScrollInterpolator() {
return scrollInterpolator;
}
/** Returns true if the scroll flags are compatible for 'collapsing' */
boolean isCollapsible() {
return (scrollFlags & SCROLL_FLAG_SCROLL) == SCROLL_FLAG_SCROLL
&& (scrollFlags & COLLAPSIBLE_FLAGS) != 0;
}
}
/**
* The default {@link Behavior} for {@link AppBarLayout}. Implements the necessary nested scroll
* handling with offsetting.
*/
// TODO: remove the base class and generic type after the widget migration is done
public static class Behavior extends BaseBehavior<AppBarLayout> {
/** Callback to allow control over any {@link AppBarLayout} dragging. */
public abstract static class DragCallback extends BaseBehavior.BaseDragCallback<AppBarLayout> {}
public Behavior() {
super();
}
public Behavior(Context context, AttributeSet attrs) {
super(context, attrs);
}
}
/**
* The default {@link Behavior} for {@link AppBarLayout}. Implements the necessary nested scroll
* handling with offsetting.
*/
// TODO: remove this base class and generic type after the widget migration is done
protected static class BaseBehavior<T extends AppBarLayout> extends HeaderBehavior<T> {
private static final int MAX_OFFSET_ANIMATION_DURATION = 600; // ms
private static final int INVALID_POSITION = -1;
/** Callback to allow control over any {@link AppBarLayout} dragging. */
// TODO: remove this base class and generic type after the widget migration
public abstract static class BaseDragCallback<T extends AppBarLayout> {
/**
* Allows control over whether the given {@link AppBarLayout} can be dragged or not.
*
* <p>Dragging is defined as a direct touch on the AppBarLayout with movement. This call does
* not affect any nested scrolling.
*
* @return true if we are in a position to scroll the AppBarLayout via a drag, false if not.
*/
public abstract boolean canDrag(@NonNull T appBarLayout);
}
private int offsetDelta;
@NestedScrollType private int lastStartedType;
private ValueAnimator offsetAnimator;
private int offsetToChildIndexOnLayout = INVALID_POSITION;
private boolean offsetToChildIndexOnLayoutIsMinHeight;
private float offsetToChildIndexOnLayoutPerc;
private WeakReference<View> lastNestedScrollingChildRef;
private BaseDragCallback onDragCallback;
public BaseBehavior() {}
public BaseBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onStartNestedScroll(
CoordinatorLayout parent,
T child,
View directTargetChild,
View target,
int nestedScrollAxes,
int type) {
// Return true if we're nested scrolling vertically, and we either have lift on scroll enabled
// or we can scroll the children.
final boolean started =
(nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0
&& (child.isLiftOnScroll() || canScrollChildren(parent, child, directTargetChild));
if (started && offsetAnimator != null) {
// Cancel any offset animation
offsetAnimator.cancel();
}
// A new nested scroll has started so clear out the previous ref
lastNestedScrollingChildRef = null;
// Track the last started type so we know if a fling is about to happen once scrolling ends
lastStartedType = type;
return started;
}
// Return true if there are scrollable children and the scrolling view is big enough to scroll.
private boolean canScrollChildren(CoordinatorLayout parent, T child, View directTargetChild) {
return child.hasScrollableChildren()
&& parent.getHeight() - directTargetChild.getHeight() <= child.getHeight();
}
@Override
public void onNestedPreScroll(
CoordinatorLayout coordinatorLayout,
T child,
View target,
int dx,
int dy,
int[] consumed,
int type) {
if (dy != 0) {
int min;
int max;
if (dy < 0) {
// We're scrolling down
min = -child.getTotalScrollRange();
max = min + child.getDownNestedPreScrollRange();
} else {
// We're scrolling up
min = -child.getUpNestedPreScrollRange();
max = 0;
}
if (min != max) {
consumed[1] = scroll(coordinatorLayout, child, dy, min, max);
}
}
if (child.isLiftOnScroll()) {
child.setLiftedState(child.shouldLift(target));
}
}
@Override
public void onNestedScroll(
CoordinatorLayout coordinatorLayout,
T child,
View target,
int dxConsumed,
int dyConsumed,
int dxUnconsumed,
int dyUnconsumed,
int type,
int[] consumed) {
if (dyUnconsumed < 0) {
// If the scrolling view is scrolling down but not consuming, it's probably be at
// the top of it's content
consumed[1] =
scroll(coordinatorLayout, child, dyUnconsumed, -child.getDownNestedScrollRange(), 0);
}
}
@Override
public void onStopNestedScroll(
CoordinatorLayout coordinatorLayout, T abl, View target, int type) {
// onStartNestedScroll for a fling will happen before onStopNestedScroll for the scroll. This
// isn't necessarily guaranteed yet, but it should be in the future. We use this to our
// advantage to check if a fling (ViewCompat.TYPE_NON_TOUCH) will start after the touch scroll
// (ViewCompat.TYPE_TOUCH) ends
if (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH) {
// If we haven't been flung, or a fling is ending
snapToChildIfNeeded(coordinatorLayout, abl);
if (abl.isLiftOnScroll()) {
abl.setLiftedState(abl.shouldLift(target));
}
}
// Keep a reference to the previous nested scrolling child
lastNestedScrollingChildRef = new WeakReference<>(target);
}
/**
* Set a callback to control any {@link AppBarLayout} dragging.
*
* @param callback the callback to use, or {@code null} to use the default behavior.
*/
public void setDragCallback(@Nullable BaseDragCallback callback) {
onDragCallback = callback;
}
private void animateOffsetTo(
final CoordinatorLayout coordinatorLayout,
final T child,
final int offset,
float velocity) {
final int distance = Math.abs(getTopBottomOffsetForScrollingSibling() - offset);
final int duration;
velocity = Math.abs(velocity);
if (velocity > 0) {
duration = 3 * Math.round(1000 * (distance / velocity));
} else {
final float distanceRatio = (float) distance / child.getHeight();
duration = (int) ((distanceRatio + 1) * 150);
}
animateOffsetWithDuration(coordinatorLayout, child, offset, duration);
}
private void animateOffsetWithDuration(
final CoordinatorLayout coordinatorLayout,
final T child,
final int offset,
final int duration) {
final int currentOffset = getTopBottomOffsetForScrollingSibling();
if (currentOffset == offset) {
if (offsetAnimator != null && offsetAnimator.isRunning()) {
offsetAnimator.cancel();
}
return;
}
if (offsetAnimator == null) {
offsetAnimator = new ValueAnimator();
offsetAnimator.setInterpolator(AnimationUtils.DECELERATE_INTERPOLATOR);
offsetAnimator.addUpdateListener(
new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animator) {
setHeaderTopBottomOffset(
coordinatorLayout, child, (int) animator.getAnimatedValue());
}
});
} else {
offsetAnimator.cancel();
}
offsetAnimator.setDuration(Math.min(duration, MAX_OFFSET_ANIMATION_DURATION));
offsetAnimator.setIntValues(currentOffset, offset);
offsetAnimator.start();
}
private int getChildIndexOnOffset(T abl, final int offset) {
for (int i = 0, count = abl.getChildCount(); i < count; i++) {
View child = abl.getChildAt(i);
int top = child.getTop();
int bottom = child.getBottom();
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (checkFlag(lp.getScrollFlags(), LayoutParams.SCROLL_FLAG_SNAP_MARGINS)) {
// Update top and bottom to include margins
top -= lp.topMargin;
bottom += lp.bottomMargin;
}
if (top <= -offset && bottom >= -offset) {
return i;
}
}
return -1;
}
private void snapToChildIfNeeded(CoordinatorLayout coordinatorLayout, T abl) {
final int offset = getTopBottomOffsetForScrollingSibling();
final int offsetChildIndex = getChildIndexOnOffset(abl, offset);
if (offsetChildIndex >= 0) {
final View offsetChild = abl.getChildAt(offsetChildIndex);
final LayoutParams lp = (LayoutParams) offsetChild.getLayoutParams();
final int flags = lp.getScrollFlags();
if ((flags & LayoutParams.FLAG_SNAP) == LayoutParams.FLAG_SNAP) {
// We're set the snap, so animate the offset to the nearest edge
int snapTop = -offsetChild.getTop();
int snapBottom = -offsetChild.getBottom();
if (offsetChildIndex == abl.getChildCount() - 1) {
// If this is the last child, we need to take the top inset into account
snapBottom += abl.getTopInset();
}
if (checkFlag(flags, LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED)) {
// If the view is set only exit until it is collapsed, we'll abide by that
snapBottom += ViewCompat.getMinimumHeight(offsetChild);
} else if (checkFlag(
flags, LayoutParams.FLAG_QUICK_RETURN | LayoutParams.SCROLL_FLAG_ENTER_ALWAYS)) {
// If it's set to always enter collapsed, it actually has two states. We
// select the state and then snap within the state
final int seam = snapBottom + ViewCompat.getMinimumHeight(offsetChild);
if (offset < seam) {
snapTop = seam;
} else {
snapBottom = seam;
}
}
if (checkFlag(flags, LayoutParams.SCROLL_FLAG_SNAP_MARGINS)) {
// Update snap destinations to include margins
snapTop += lp.topMargin;
snapBottom -= lp.bottomMargin;
}
final int newOffset = offset < (snapBottom + snapTop) / 2 ? snapBottom : snapTop;
animateOffsetTo(
coordinatorLayout, abl, MathUtils.clamp(newOffset, -abl.getTotalScrollRange(), 0), 0);
}
}
}
private static boolean checkFlag(final int flags, final int check) {
return (flags & check) == check;
}
@Override
public boolean onMeasureChild(
CoordinatorLayout parent,
T child,
int parentWidthMeasureSpec,
int widthUsed,
int parentHeightMeasureSpec,
int heightUsed) {
final CoordinatorLayout.LayoutParams lp =
(CoordinatorLayout.LayoutParams) child.getLayoutParams();
if (lp.height == CoordinatorLayout.LayoutParams.WRAP_CONTENT) {
// If the view is set to wrap on it's height, CoordinatorLayout by default will
// cap the view at the CoL's height. Since the AppBarLayout can scroll, this isn't
// what we actually want, so we measure it ourselves with an unspecified spec to
// allow the child to be larger than it's parent
parent.onMeasureChild(
child,
parentWidthMeasureSpec,
widthUsed,
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
heightUsed);
return true;
}
// Let the parent handle it as normal
return super.onMeasureChild(
parent, child, parentWidthMeasureSpec, widthUsed, parentHeightMeasureSpec, heightUsed);
}
@Override
public boolean onLayoutChild(CoordinatorLayout parent, T abl, int layoutDirection) {
boolean handled = super.onLayoutChild(parent, abl, layoutDirection);
// The priority for actions here is (first which is true wins):
// 1. forced pending actions
// 2. offsets for restorations
// 3. non-forced pending actions
final int pendingAction = abl.getPendingAction();
if (offsetToChildIndexOnLayout >= 0 && (pendingAction & PENDING_ACTION_FORCE) == 0) {
View child = abl.getChildAt(offsetToChildIndexOnLayout);
int offset = -child.getBottom();
if (offsetToChildIndexOnLayoutIsMinHeight) {
offset += ViewCompat.getMinimumHeight(child) + abl.getTopInset();
} else {
offset += Math.round(child.getHeight() * offsetToChildIndexOnLayoutPerc);
}
setHeaderTopBottomOffset(parent, abl, offset);
} else if (pendingAction != PENDING_ACTION_NONE) {
final boolean animate = (pendingAction & PENDING_ACTION_ANIMATE_ENABLED) != 0;
if ((pendingAction & PENDING_ACTION_COLLAPSED) != 0) {
final int offset = -abl.getUpNestedPreScrollRange();
if (animate) {
animateOffsetTo(parent, abl, offset, 0);
} else {
setHeaderTopBottomOffset(parent, abl, offset);
}
} else if ((pendingAction & PENDING_ACTION_EXPANDED) != 0) {
if (animate) {
animateOffsetTo(parent, abl, 0, 0);
} else {
setHeaderTopBottomOffset(parent, abl, 0);
}
}
}
// Finally reset any pending states
abl.resetPendingAction();
offsetToChildIndexOnLayout = INVALID_POSITION;
// We may have changed size, so let's constrain the top and bottom offset correctly,
// just in case we're out of the bounds
setTopAndBottomOffset(
MathUtils.clamp(getTopAndBottomOffset(), -abl.getTotalScrollRange(), 0));
// Update the AppBarLayout's drawable state for any elevation changes. This is needed so that
// the elevation is set in the first layout, so that we don't get a visual jump pre-N (due to
// the draw dispatch skip)
updateAppBarLayoutDrawableState(
parent, abl, getTopAndBottomOffset(), 0 /* direction */, true /* forceJump */);
// Make sure we dispatch the offset update
abl.dispatchOffsetUpdates(getTopAndBottomOffset());
return handled;
}
@Override
boolean canDragView(T view) {
if (onDragCallback != null) {
// If there is a drag callback set, it's in control
return onDragCallback.canDrag(view);
}
// Else we'll use the default behaviour of seeing if it can scroll down
if (lastNestedScrollingChildRef != null) {
// If we have a reference to a scrolling view, check it
final View scrollingView = lastNestedScrollingChildRef.get();
return scrollingView != null
&& scrollingView.isShown()
&& !scrollingView.canScrollVertically(-1);
} else {
// Otherwise we assume that the scrolling view hasn't been scrolled and can drag.
return true;
}
}
@Override
void onFlingFinished(CoordinatorLayout parent, T layout) {
// At the end of a manual fling, check to see if we need to snap to the edge-child
snapToChildIfNeeded(parent, layout);
if (layout.isLiftOnScroll()) {
layout.setLiftedState(layout.shouldLift(findFirstScrollingChild(parent)));
}
}
@Override
int getMaxDragOffset(T view) {
return -view.getDownNestedScrollRange();
}
@Override
int getScrollRangeForDragFling(T view) {
return view.getTotalScrollRange();
}
@Override
int setHeaderTopBottomOffset(
CoordinatorLayout coordinatorLayout,
T appBarLayout,
int newOffset,
int minOffset,
int maxOffset) {
final int curOffset = getTopBottomOffsetForScrollingSibling();
int consumed = 0;
if (minOffset != 0 && curOffset >= minOffset && curOffset <= maxOffset) {
// If we have some scrolling range, and we're currently within the min and max
// offsets, calculate a new offset
newOffset = MathUtils.clamp(newOffset, minOffset, maxOffset);
if (curOffset != newOffset) {
final int interpolatedOffset =
appBarLayout.hasChildWithInterpolator()
? interpolateOffset(appBarLayout, newOffset)
: newOffset;
final boolean offsetChanged = setTopAndBottomOffset(interpolatedOffset);
// Update how much dy we have consumed
consumed = curOffset - newOffset;
// Update the stored sibling offset
offsetDelta = newOffset - interpolatedOffset;
if (!offsetChanged && appBarLayout.hasChildWithInterpolator()) {
// If the offset hasn't changed and we're using an interpolated scroll
// then we need to keep any dependent views updated. CoL will do this for
// us when we move, but we need to do it manually when we don't (as an
// interpolated scroll may finish early).
coordinatorLayout.dispatchDependentViewsChanged(appBarLayout);
}
// Dispatch the updates to any listeners
appBarLayout.dispatchOffsetUpdates(getTopAndBottomOffset());
// Update the AppBarLayout's drawable state (for any elevation changes)
updateAppBarLayoutDrawableState(
coordinatorLayout,
appBarLayout,
newOffset,
newOffset < curOffset ? -1 : 1,
false /* forceJump */);
}
} else {
// Reset the offset delta
offsetDelta = 0;
}
return consumed;
}
@VisibleForTesting
boolean isOffsetAnimatorRunning() {
return offsetAnimator != null && offsetAnimator.isRunning();
}
private int interpolateOffset(T layout, final int offset) {
final int absOffset = Math.abs(offset);
for (int i = 0, z = layout.getChildCount(); i < z; i++) {
final View child = layout.getChildAt(i);
final AppBarLayout.LayoutParams childLp = (LayoutParams) child.getLayoutParams();
final Interpolator interpolator = childLp.getScrollInterpolator();
if (absOffset >= child.getTop() && absOffset <= child.getBottom()) {
if (interpolator != null) {
int childScrollableHeight = 0;
final int flags = childLp.getScrollFlags();
if ((flags & LayoutParams.SCROLL_FLAG_SCROLL) != 0) {
// We're set to scroll so add the child's height plus margin
childScrollableHeight += child.getHeight() + childLp.topMargin + childLp.bottomMargin;
if ((flags & LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED) != 0) {
// For a collapsing scroll, we to take the collapsed height
// into account.
childScrollableHeight -= ViewCompat.getMinimumHeight(child);
}
}
if (ViewCompat.getFitsSystemWindows(child)) {
childScrollableHeight -= layout.getTopInset();
}
if (childScrollableHeight > 0) {
final int offsetForView = absOffset - child.getTop();
final int interpolatedDiff =
Math.round(
childScrollableHeight
* interpolator.getInterpolation(
offsetForView / (float) childScrollableHeight));
return Integer.signum(offset) * (child.getTop() + interpolatedDiff);
}
}
// If we get to here then the view on the offset isn't suitable for interpolated
// scrolling. So break out of the loop
break;
}
}
return offset;
}
private void updateAppBarLayoutDrawableState(
final CoordinatorLayout parent,
final T layout,
final int offset,
final int direction,
final boolean forceJump) {
final View child = getAppBarChildOnOffset(layout, offset);
if (child != null) {
final AppBarLayout.LayoutParams childLp = (LayoutParams) child.getLayoutParams();
final int flags = childLp.getScrollFlags();
boolean lifted = false;
if ((flags & LayoutParams.SCROLL_FLAG_SCROLL) != 0) {
final int minHeight = ViewCompat.getMinimumHeight(child);
if (direction > 0
&& (flags
& (LayoutParams.SCROLL_FLAG_ENTER_ALWAYS
| LayoutParams.SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED))
!= 0) {
// We're set to enter always collapsed so we are only collapsed when
// being scrolled down, and in a collapsed offset
lifted = -offset >= child.getBottom() - minHeight - layout.getTopInset();
} else if ((flags & LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED) != 0) {
// We're set to exit until collapsed, so any offset which results in
// the minimum height (or less) being shown is collapsed
lifted = -offset >= child.getBottom() - minHeight - layout.getTopInset();
}
}
if (layout.isLiftOnScroll()) {
// Use first scrolling child as default scrolling view for updating lifted state because
// it represents the content that would be scrolled beneath the app bar.
lifted = layout.shouldLift(findFirstScrollingChild(parent));
}
final boolean changed = layout.setLiftedState(lifted);
if (Build.VERSION.SDK_INT >= 11
&& (forceJump || (changed && shouldJumpElevationState(parent, layout)))) {
// If the collapsed state changed, we may need to
// jump to the current state if we have an overlapping view
layout.jumpDrawablesToCurrentState();
}
}
}
private boolean shouldJumpElevationState(CoordinatorLayout parent, T layout) {
// We should jump the elevated state if we have a dependent scrolling view which has
// an overlapping top (i.e. overlaps us)
final List<View> dependencies = parent.getDependents(layout);
for (int i = 0, size = dependencies.size(); i < size; i++) {
final View dependency = dependencies.get(i);
final CoordinatorLayout.LayoutParams lp =
(CoordinatorLayout.LayoutParams) dependency.getLayoutParams();
final CoordinatorLayout.Behavior behavior = lp.getBehavior();
if (behavior instanceof ScrollingViewBehavior) {
return ((ScrollingViewBehavior) behavior).getOverlayTop() != 0;
}
}
return false;
}
private static View getAppBarChildOnOffset(final AppBarLayout layout, final int offset) {
final int absOffset = Math.abs(offset);
for (int i = 0, z = layout.getChildCount(); i < z; i++) {
final View child = layout.getChildAt(i);
if (absOffset >= child.getTop() && absOffset <= child.getBottom()) {
return child;
}
}
return null;
}
@Nullable
private View findFirstScrollingChild(CoordinatorLayout parent) {
for (int i = 0, z = parent.getChildCount(); i < z; i++) {
final View child = parent.getChildAt(i);
if (child instanceof NestedScrollingChild
|| child instanceof ListView
|| child instanceof ScrollView) {
return child;
}
}
return null;
}
@Override
int getTopBottomOffsetForScrollingSibling() {
return getTopAndBottomOffset() + offsetDelta;
}
@Override
public Parcelable onSaveInstanceState(CoordinatorLayout parent, T abl) {
final Parcelable superState = super.onSaveInstanceState(parent, abl);
final int offset = getTopAndBottomOffset();
// Try and find the first visible child...
for (int i = 0, count = abl.getChildCount(); i < count; i++) {
View child = abl.getChildAt(i);
final int visBottom = child.getBottom() + offset;
if (child.getTop() + offset <= 0 && visBottom >= 0) {
final SavedState ss = new SavedState(superState);
ss.firstVisibleChildIndex = i;
ss.firstVisibleChildAtMinimumHeight =
visBottom == (ViewCompat.getMinimumHeight(child) + abl.getTopInset());
ss.firstVisibleChildPercentageShown = visBottom / (float) child.getHeight();
return ss;
}
}
// Else we'll just return the super state
return superState;
}
@Override
public void onRestoreInstanceState(CoordinatorLayout parent, T appBarLayout, Parcelable state) {
if (state instanceof SavedState) {
final SavedState ss = (SavedState) state;
super.onRestoreInstanceState(parent, appBarLayout, ss.getSuperState());
offsetToChildIndexOnLayout = ss.firstVisibleChildIndex;
offsetToChildIndexOnLayoutPerc = ss.firstVisibleChildPercentageShown;
offsetToChildIndexOnLayoutIsMinHeight = ss.firstVisibleChildAtMinimumHeight;
} else {
super.onRestoreInstanceState(parent, appBarLayout, state);
offsetToChildIndexOnLayout = INVALID_POSITION;
}
}
/** A {@link Parcelable} implementation for {@link AppBarLayout}. */
protected static class SavedState extends AbsSavedState {
int firstVisibleChildIndex;
float firstVisibleChildPercentageShown;
boolean firstVisibleChildAtMinimumHeight;
public SavedState(Parcel source, ClassLoader loader) {
super(source, loader);
firstVisibleChildIndex = source.readInt();
firstVisibleChildPercentageShown = source.readFloat();
firstVisibleChildAtMinimumHeight = source.readByte() != 0;
}
public SavedState(Parcelable superState) {
super(superState);
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeInt(firstVisibleChildIndex);
dest.writeFloat(firstVisibleChildPercentageShown);
dest.writeByte((byte) (firstVisibleChildAtMinimumHeight ? 1 : 0));
}
public static final Creator<SavedState> CREATOR =
new ClassLoaderCreator<SavedState>() {
@Override
public SavedState createFromParcel(Parcel source, ClassLoader loader) {
return new SavedState(source, loader);
}
@Override
public SavedState createFromParcel(Parcel source) {
return new SavedState(source, null);
}
@Override
public SavedState[] newArray(int size) {
return new SavedState[size];
}
};
}
}
/**
* Behavior which should be used by {@link View}s which can scroll vertically and support nested
* scrolling to automatically scroll any {@link AppBarLayout} siblings.
*/
public static class ScrollingViewBehavior extends HeaderScrollingViewBehavior {
public ScrollingViewBehavior() {}
public ScrollingViewBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
final TypedArray a =
context.obtainStyledAttributes(attrs, R.styleable.ScrollingViewBehavior_Layout);
setOverlayTop(
a.getDimensionPixelSize(R.styleable.ScrollingViewBehavior_Layout_behavior_overlapTop, 0));
a.recycle();
}
@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
// We depend on any AppBarLayouts
return dependency instanceof AppBarLayout;
}
@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
offsetChildAsNeeded(child, dependency);
updateLiftedStateIfNeeded(child, dependency);
return false;
}
@Override
public boolean onRequestChildRectangleOnScreen(
CoordinatorLayout parent, View child, Rect rectangle, boolean immediate) {
final AppBarLayout header = findFirstDependency(parent.getDependencies(child));
if (header != null) {
// Offset the rect by the child's left/top
rectangle.offset(child.getLeft(), child.getTop());
final Rect parentRect = tempRect1;
parentRect.set(0, 0, parent.getWidth(), parent.getHeight());
if (!parentRect.contains(rectangle)) {
// If the rectangle can not be fully seen the visible bounds, collapse
// the AppBarLayout
header.setExpanded(false, !immediate);
return true;
}
}
return false;
}
private void offsetChildAsNeeded(View child, View dependency) {
final CoordinatorLayout.Behavior behavior =
((CoordinatorLayout.LayoutParams) dependency.getLayoutParams()).getBehavior();
if (behavior instanceof BaseBehavior) {
// Offset the child, pinning it to the bottom the header-dependency, maintaining
// any vertical gap and overlap
final BaseBehavior ablBehavior = (BaseBehavior) behavior;
ViewCompat.offsetTopAndBottom(
child,
(dependency.getBottom() - child.getTop())
+ ablBehavior.offsetDelta
+ getVerticalLayoutGap()
- getOverlapPixelsForOffset(dependency));
}
}
@Override
float getOverlapRatioForOffset(final View header) {
if (header instanceof AppBarLayout) {
final AppBarLayout abl = (AppBarLayout) header;
final int totalScrollRange = abl.getTotalScrollRange();
final int preScrollDown = abl.getDownNestedPreScrollRange();
final int offset = getAppBarLayoutOffset(abl);
if (preScrollDown != 0 && (totalScrollRange + offset) <= preScrollDown) {
// If we're in a pre-scroll down. Don't use the offset at all.
return 0;
} else {
final int availScrollRange = totalScrollRange - preScrollDown;
if (availScrollRange != 0) {
// Else we'll use a interpolated ratio of the overlap, depending on offset
return 1f + (offset / (float) availScrollRange);
}
}
}
return 0f;
}
private static int getAppBarLayoutOffset(AppBarLayout abl) {
final CoordinatorLayout.Behavior behavior =
((CoordinatorLayout.LayoutParams) abl.getLayoutParams()).getBehavior();
if (behavior instanceof BaseBehavior) {
return ((BaseBehavior) behavior).getTopBottomOffsetForScrollingSibling();
}
return 0;
}
@Override
AppBarLayout findFirstDependency(List<View> views) {
for (int i = 0, z = views.size(); i < z; i++) {
View view = views.get(i);
if (view instanceof AppBarLayout) {
return (AppBarLayout) view;
}
}
return null;
}
@Override
int getScrollRange(View v) {
if (v instanceof AppBarLayout) {
return ((AppBarLayout) v).getTotalScrollRange();
} else {
return super.getScrollRange(v);
}
}
private void updateLiftedStateIfNeeded(View child, View dependency) {
if (dependency instanceof AppBarLayout) {
AppBarLayout appBarLayout = (AppBarLayout) dependency;
if (appBarLayout.isLiftOnScroll()) {
appBarLayout.setLiftedState(appBarLayout.shouldLift(child));
}
}
}
}
}