mirror of
https://github.com/material-components/material-components-android.git
synced 2026-01-19 19:41:35 +08:00
Resolves https://github.com/material-components/material-components-android/issues/3054 PiperOrigin-RevId: 491440304
2600 lines
97 KiB
Java
2600 lines
97 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 static androidx.core.math.MathUtils.clamp;
|
|
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_BACKWARD;
|
|
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_FORWARD;
|
|
import static com.google.android.material.theme.overlay.MaterialThemeOverlay.wrap;
|
|
import static java.lang.Math.abs;
|
|
|
|
import android.animation.TimeInterpolator;
|
|
import android.animation.ValueAnimator;
|
|
import android.animation.ValueAnimator.AnimatorUpdateListener;
|
|
import android.content.Context;
|
|
import android.content.res.ColorStateList;
|
|
import android.content.res.TypedArray;
|
|
import android.graphics.Canvas;
|
|
import android.graphics.Rect;
|
|
import android.graphics.drawable.ColorDrawable;
|
|
import android.graphics.drawable.Drawable;
|
|
import android.os.Build;
|
|
import android.os.Build.VERSION;
|
|
import android.os.Build.VERSION_CODES;
|
|
import android.os.Parcel;
|
|
import android.os.Parcelable;
|
|
import androidx.appcompat.content.res.AppCompatResources;
|
|
import android.util.AttributeSet;
|
|
import android.view.KeyEvent;
|
|
import android.view.View;
|
|
import android.view.ViewGroup;
|
|
import android.view.ViewOutlineProvider;
|
|
import android.view.animation.Interpolator;
|
|
import android.widget.LinearLayout;
|
|
import android.widget.ListView;
|
|
import android.widget.ScrollView;
|
|
import androidx.annotation.ColorInt;
|
|
import androidx.annotation.Dimension;
|
|
import androidx.annotation.DrawableRes;
|
|
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 androidx.coordinatorlayout.widget.CoordinatorLayout;
|
|
import androidx.core.graphics.drawable.DrawableCompat;
|
|
import androidx.core.util.ObjectsCompat;
|
|
import androidx.core.view.AccessibilityDelegateCompat;
|
|
import androidx.core.view.NestedScrollingChild;
|
|
import androidx.core.view.ViewCompat;
|
|
import androidx.core.view.ViewCompat.NestedScrollType;
|
|
import androidx.core.view.WindowInsetsCompat;
|
|
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
|
|
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat;
|
|
import androidx.core.view.accessibility.AccessibilityViewCommand;
|
|
import androidx.customview.view.AbsSavedState;
|
|
import com.google.android.material.animation.AnimationUtils;
|
|
import com.google.android.material.appbar.AppBarLayout.BaseBehavior.SavedState;
|
|
import com.google.android.material.internal.ThemeEnforcement;
|
|
import com.google.android.material.motion.MotionUtils;
|
|
import com.google.android.material.resources.MaterialResources;
|
|
import com.google.android.material.shape.MaterialShapeDrawable;
|
|
import com.google.android.material.shape.MaterialShapeUtils;
|
|
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>
|
|
* <androidx.coordinatorlayout.widget.CoordinatorLayout
|
|
* xmlns:android="http://schemas.android.com/apk/res/android"
|
|
* xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
* android:layout_width="match_parent"
|
|
* android:layout_height="match_parent">
|
|
*
|
|
* <androidx.core.widget.NestedScrollView
|
|
* android:layout_width="match_parent"
|
|
* android:layout_height="match_parent"
|
|
* app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
|
*
|
|
* <!-- Your scrolling content -->
|
|
*
|
|
* </androidx.core.widget.NestedScrollView>
|
|
*
|
|
* <com.google.android.material.appbar.AppBarLayout
|
|
* android:layout_height="wrap_content"
|
|
* android:layout_width="match_parent">
|
|
*
|
|
* <androidx.appcompat.widget.Toolbar
|
|
* ...
|
|
* app:layout_scrollFlags="scroll|enterAlways"/>
|
|
*
|
|
* <com.google.android.material.tabs.TabLayout
|
|
* ...
|
|
* app:layout_scrollFlags="scroll|enterAlways"/>
|
|
*
|
|
* </com.google.android.material.appbar.AppBarLayout>
|
|
*
|
|
* </androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
* </pre>
|
|
*
|
|
* @see <a href="https://material.io/components/app-bars-top#anatomy">
|
|
* https://material.io/components/app-bars-top#anatomy</a>
|
|
*/
|
|
public class AppBarLayout extends LinearLayout implements CoordinatorLayout.AttachedBehavior {
|
|
|
|
static final int PENDING_ACTION_NONE = 0x0;
|
|
static final int PENDING_ACTION_EXPANDED = 0x1;
|
|
static final int PENDING_ACTION_COLLAPSED = 1 << 1;
|
|
static final int PENDING_ACTION_ANIMATE_ENABLED = 1 << 2;
|
|
static final int PENDING_ACTION_FORCE = 1 << 3;
|
|
|
|
/**
|
|
* Interface definition for a callback to be invoked when an {@link AppBarLayout}'s vertical
|
|
* offset changes.
|
|
*/
|
|
// TODO(b/76413401): 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(b/76413401): update this interface after the widget migration
|
|
public interface OnOffsetChangedListener extends BaseOnOffsetChangedListener<AppBarLayout> {
|
|
@Override
|
|
void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset);
|
|
}
|
|
|
|
/**
|
|
* Definition for a callback to be invoked when the lift on scroll elevation and background color
|
|
* change.
|
|
*/
|
|
public interface LiftOnScrollListener {
|
|
void onUpdate(@Dimension float elevation, @ColorInt int backgroundColor);
|
|
}
|
|
|
|
private static final int DEF_STYLE_RES = R.style.Widget_Design_AppBarLayout;
|
|
private static final int INVALID_SCROLL_RANGE = -1;
|
|
|
|
private int currentOffset;
|
|
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;
|
|
|
|
@Nullable 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;
|
|
@Nullable private final ColorStateList liftOnScrollColor;
|
|
@Nullable private ValueAnimator liftOnScrollColorAnimator;
|
|
@Nullable private AnimatorUpdateListener liftOnScrollColorUpdateListener;
|
|
private final List<LiftOnScrollListener> liftOnScrollListeners = new ArrayList<>();
|
|
|
|
private final long liftOnScrollColorDuration;
|
|
private final TimeInterpolator liftOnScrollColorInterpolator;
|
|
|
|
private int[] tmpStatesArray;
|
|
|
|
@Nullable private Drawable statusBarForeground;
|
|
|
|
private final float appBarElevation;
|
|
|
|
private Behavior behavior;
|
|
|
|
public AppBarLayout(@NonNull Context context) {
|
|
this(context, null);
|
|
}
|
|
|
|
public AppBarLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
|
|
this(context, attrs, R.attr.appBarLayoutStyle);
|
|
}
|
|
|
|
public AppBarLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
|
super(wrap(context, attrs, defStyleAttr, DEF_STYLE_RES), attrs, defStyleAttr);
|
|
// Ensure we are using the correctly themed context rather than the context that was passed in.
|
|
context = getContext();
|
|
setOrientation(VERTICAL);
|
|
|
|
if (Build.VERSION.SDK_INT >= 21) {
|
|
// Use the bounds view outline provider so that we cast a shadow, even without a
|
|
// background
|
|
if (getOutlineProvider() == ViewOutlineProvider.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, DEF_STYLE_RES);
|
|
}
|
|
|
|
final TypedArray a =
|
|
ThemeEnforcement.obtainStyledAttributes(
|
|
context, attrs, R.styleable.AppBarLayout, defStyleAttr, DEF_STYLE_RES);
|
|
|
|
ViewCompat.setBackground(this, a.getDrawable(R.styleable.AppBarLayout_android_background));
|
|
|
|
liftOnScrollColor =
|
|
MaterialResources.getColorStateList(
|
|
context, a, R.styleable.AppBarLayout_liftOnScrollColor);
|
|
|
|
if (getBackground() instanceof ColorDrawable) {
|
|
ColorDrawable background = (ColorDrawable) getBackground();
|
|
MaterialShapeDrawable materialShapeDrawable = new MaterialShapeDrawable();
|
|
materialShapeDrawable.setFillColor(ColorStateList.valueOf(background.getColor()));
|
|
// If there is a lift on scroll color specified, we do not initialize the elevation overlay
|
|
// and set the alpha to zero manually.
|
|
if (liftOnScrollColor != null) {
|
|
initializeLiftOnScrollWithColor(materialShapeDrawable);
|
|
} else {
|
|
initializeLiftOnScrollWithElevation(context, materialShapeDrawable);
|
|
}
|
|
ViewCompat.setBackground(this, materialShapeDrawable);
|
|
}
|
|
|
|
liftOnScrollColorDuration = MotionUtils.resolveThemeDuration(context,
|
|
R.attr.motionDurationMedium2,
|
|
getResources().getInteger(R.integer.app_bar_elevation_anim_duration));
|
|
liftOnScrollColorInterpolator = MotionUtils.resolveThemeInterpolator(context,
|
|
R.attr.motionEasingStandardInterpolator, AnimationUtils.LINEAR_INTERPOLATOR);
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
// TODO(b/249786834): This should be a customizable attribute.
|
|
appBarElevation = getResources().getDimension(R.dimen.design_appbar_elevation);
|
|
|
|
liftOnScroll = a.getBoolean(R.styleable.AppBarLayout_liftOnScroll, false);
|
|
liftOnScrollTargetViewId =
|
|
a.getResourceId(R.styleable.AppBarLayout_liftOnScrollTargetViewId, View.NO_ID);
|
|
|
|
setStatusBarForeground(a.getDrawable(R.styleable.AppBarLayout_statusBarForeground));
|
|
a.recycle();
|
|
|
|
ViewCompat.setOnApplyWindowInsetsListener(
|
|
this,
|
|
new androidx.core.view.OnApplyWindowInsetsListener() {
|
|
@Override
|
|
public WindowInsetsCompat onApplyWindowInsets(View v, WindowInsetsCompat insets) {
|
|
return onWindowInsetChanged(insets);
|
|
}
|
|
});
|
|
}
|
|
|
|
private void initializeLiftOnScrollWithColor(MaterialShapeDrawable background) {
|
|
background.setAlpha(lifted ? 255 : 0);
|
|
background.setFillColor(liftOnScrollColor);
|
|
liftOnScrollColorUpdateListener = valueAnimator -> {
|
|
float alpha = (float) valueAnimator.getAnimatedValue();
|
|
background.setAlpha((int) alpha);
|
|
|
|
for (LiftOnScrollListener liftOnScrollListener : liftOnScrollListeners) {
|
|
if (background.getFillColor() != null) {
|
|
liftOnScrollListener.onUpdate(
|
|
0, background.getFillColor().withAlpha((int) alpha).getDefaultColor());
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
private void initializeLiftOnScrollWithElevation(
|
|
Context context, MaterialShapeDrawable background) {
|
|
background.initializeElevationOverlay(context);
|
|
liftOnScrollColorUpdateListener = valueAnimator -> {
|
|
float elevation = (float) valueAnimator.getAnimatedValue();
|
|
background.setElevation(elevation);
|
|
if (statusBarForeground instanceof MaterialShapeDrawable) {
|
|
((MaterialShapeDrawable) statusBarForeground).setElevation(elevation);
|
|
}
|
|
for (LiftOnScrollListener liftOnScrollListener : liftOnScrollListeners) {
|
|
liftOnScrollListener.onUpdate(elevation, background.getResolvedTintColor());
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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(@Nullable 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(b/76413401): change back to removeOnOffsetChangedListener once the widget migration is
|
|
// finished since the shim class needs to implement this method.
|
|
@SuppressWarnings("FunctionalInterfaceClash")
|
|
public void removeOnOffsetChangedListener(@Nullable BaseOnOffsetChangedListener listener) {
|
|
if (listeners != null && listener != null) {
|
|
listeners.remove(listener);
|
|
}
|
|
}
|
|
|
|
@SuppressWarnings("FunctionalInterfaceClash")
|
|
public void removeOnOffsetChangedListener(OnOffsetChangedListener listener) {
|
|
removeOnOffsetChangedListener((BaseOnOffsetChangedListener) listener);
|
|
}
|
|
|
|
/**
|
|
* Add a {@link LiftOnScrollListener} that will be called when the lift on scroll elevation and
|
|
* background color of this {@link AppBarLayout} change.
|
|
*/
|
|
public void addLiftOnScrollListener(@NonNull LiftOnScrollListener liftOnScrollListener) {
|
|
liftOnScrollListeners.add(liftOnScrollListener);
|
|
}
|
|
|
|
/** Remove a previously added {@link LiftOnScrollListener}. */
|
|
public boolean removeLiftOnScrollListener(@NonNull LiftOnScrollListener liftOnScrollListener) {
|
|
return liftOnScrollListeners.remove(liftOnScrollListener);
|
|
}
|
|
|
|
/** Remove all previously added {@link LiftOnScrollListener}s. */
|
|
public void clearLiftOnScrollListener() {
|
|
liftOnScrollListeners.clear();
|
|
}
|
|
|
|
/**
|
|
* Set the drawable to use for the status bar foreground drawable. Providing null will disable the
|
|
* scrim functionality.
|
|
*
|
|
* <p>This scrim is only shown when we have been given a top system inset.
|
|
*
|
|
* @param drawable the drawable to display
|
|
* @attr ref R.styleable#AppBarLayout_statusBarForeground
|
|
* @see #getStatusBarForeground()
|
|
*/
|
|
public void setStatusBarForeground(@Nullable Drawable drawable) {
|
|
if (statusBarForeground != drawable) {
|
|
if (statusBarForeground != null) {
|
|
statusBarForeground.setCallback(null);
|
|
}
|
|
statusBarForeground = drawable != null ? drawable.mutate() : null;
|
|
if (statusBarForeground != null) {
|
|
if (statusBarForeground.isStateful()) {
|
|
statusBarForeground.setState(getDrawableState());
|
|
}
|
|
DrawableCompat.setLayoutDirection(statusBarForeground, ViewCompat.getLayoutDirection(this));
|
|
statusBarForeground.setVisible(getVisibility() == VISIBLE, false);
|
|
statusBarForeground.setCallback(this);
|
|
}
|
|
updateWillNotDraw();
|
|
ViewCompat.postInvalidateOnAnimation(this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the color to use for the status bar foreground.
|
|
*
|
|
* <p>This scrim is only shown when we have been given a top system inset.
|
|
*
|
|
* @param color the color to display
|
|
* @attr ref R.styleable#AppBarLayout_statusBarForeground
|
|
* @see #getStatusBarForeground()
|
|
*/
|
|
public void setStatusBarForegroundColor(@ColorInt int color) {
|
|
setStatusBarForeground(new ColorDrawable(color));
|
|
}
|
|
|
|
/**
|
|
* Set the drawable to use for the status bar foreground from resources.
|
|
*
|
|
* <p>This scrim is only shown when we have been given a top system inset.
|
|
*
|
|
* @param resId drawable resource id
|
|
* @attr ref R.styleable#AppBarLayout_statusBarForeground
|
|
* @see #getStatusBarForeground()
|
|
*/
|
|
public void setStatusBarForegroundResource(@DrawableRes int resId) {
|
|
setStatusBarForeground(AppCompatResources.getDrawable(getContext(), resId));
|
|
}
|
|
|
|
/**
|
|
* Returns the drawable which is used for the status bar foreground.
|
|
*
|
|
* @see #setStatusBarForeground(Drawable)
|
|
* @attr ref R.styleable#AppBarLayout_statusBarForeground
|
|
*/
|
|
@Nullable
|
|
public Drawable getStatusBarForeground() {
|
|
return statusBarForeground;
|
|
}
|
|
|
|
@Override
|
|
public void draw(@NonNull Canvas canvas) {
|
|
super.draw(canvas);
|
|
|
|
// Draw the status bar foreground drawable if we have a top inset
|
|
if (shouldDrawStatusBarForeground()) {
|
|
int saveCount = canvas.save();
|
|
canvas.translate(0f, -currentOffset);
|
|
statusBarForeground.draw(canvas);
|
|
canvas.restoreToCount(saveCount);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected void drawableStateChanged() {
|
|
super.drawableStateChanged();
|
|
|
|
final int[] state = getDrawableState();
|
|
|
|
Drawable d = statusBarForeground;
|
|
if (d != null && d.isStateful() && d.setState(state)) {
|
|
invalidateDrawable(d);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected boolean verifyDrawable(@NonNull Drawable who) {
|
|
return super.verifyDrawable(who) || who == statusBarForeground;
|
|
}
|
|
|
|
@Override
|
|
public void setVisibility(int visibility) {
|
|
super.setVisibility(visibility);
|
|
|
|
final boolean visible = visibility == VISIBLE;
|
|
if (statusBarForeground != null) {
|
|
statusBarForeground.setVisible(visible, false);
|
|
}
|
|
}
|
|
|
|
@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 =
|
|
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;
|
|
case MeasureSpec.EXACTLY:
|
|
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 (statusBarForeground != null) {
|
|
statusBarForeground.setBounds(0, 0, getWidth(), getTopInset());
|
|
}
|
|
|
|
// If the user has set liftable manually, don't set liftable state automatically.
|
|
if (!liftableOverride) {
|
|
setLiftableState(liftOnScroll || hasCollapsibleChild());
|
|
}
|
|
}
|
|
|
|
private void updateWillNotDraw() {
|
|
setWillNotDraw(!shouldDrawStatusBarForeground());
|
|
}
|
|
|
|
private boolean shouldDrawStatusBarForeground() {
|
|
return statusBarForeground != null && getTopInset() > 0;
|
|
}
|
|
|
|
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() {
|
|
// Saves the current scrolling state when we need to recalculate scroll ranges
|
|
// If the total scroll range is not known yet, the ABL is never scrolled.
|
|
// If there's a pending action, we should skip this step and respect the pending action.
|
|
SavedState savedState =
|
|
behavior == null
|
|
|| totalScrollRange == INVALID_SCROLL_RANGE
|
|
|| pendingAction != PENDING_ACTION_NONE
|
|
? null : behavior.saveScrollState(AbsSavedState.EMPTY_STATE, this);
|
|
// Invalidate the scroll ranges
|
|
totalScrollRange = INVALID_SCROLL_RANGE;
|
|
downPreScrollRange = INVALID_SCROLL_RANGE;
|
|
downScrollRange = INVALID_SCROLL_RANGE;
|
|
// Restores the previous scrolling state. Don't override if there's a previously saved state
|
|
// which has not be restored yet. Multiple re-measuring can happen before the scroll state
|
|
// is actually restored. We don't want to restore the state in-between those re-measuring,
|
|
// since they can be incorrect.
|
|
if (savedState != null) {
|
|
behavior.restoreScrollState(savedState, false);
|
|
}
|
|
}
|
|
|
|
@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);
|
|
}
|
|
|
|
@Override
|
|
protected void onAttachedToWindow() {
|
|
super.onAttachedToWindow();
|
|
|
|
MaterialShapeUtils.setParentAbsoluteElevation(this);
|
|
}
|
|
|
|
@Override
|
|
@NonNull
|
|
public CoordinatorLayout.Behavior<AppBarLayout> getBehavior() {
|
|
behavior = new AppBarLayout.Behavior();
|
|
return behavior;
|
|
}
|
|
|
|
@RequiresApi(VERSION_CODES.LOLLIPOP)
|
|
@Override
|
|
public void setElevation(float elevation) {
|
|
super.setElevation(elevation);
|
|
|
|
MaterialShapeUtils.setElevation(this, elevation);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
if (child.getVisibility() == GONE) {
|
|
// Gone views should not be included in the scroll range calculation.
|
|
continue;
|
|
}
|
|
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);
|
|
if (child.getVisibility() == GONE) {
|
|
// Gone views should not be included in the scroll range calculation.
|
|
continue;
|
|
}
|
|
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);
|
|
if (child.getVisibility() == GONE) {
|
|
// Gone views should not be included in the scroll range calculation.
|
|
continue;
|
|
}
|
|
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 onOffsetChanged(int offset) {
|
|
currentOffset = offset;
|
|
|
|
if (!willNotDraw()) {
|
|
ViewCompat.postInvalidateOnAnimation(this);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
/**
|
|
* Sets whether the {@link AppBarLayout} lifted state corresponding to {@link
|
|
* #setLiftable(boolean)} and {@link #setLifted(boolean)} will be overridden manually.
|
|
*
|
|
* <p>If true, this means that the {@link AppBarLayout} will not manage its own lifted state and
|
|
* it should instead be manually updated via {@link #setLifted(boolean)}. If false, the {@link
|
|
* AppBarLayout} will manage its lifted state based on the scrolling sibling view.
|
|
*
|
|
* <p>Note that calling {@link #setLiftable(boolean)} will result in this liftable override being
|
|
* enabled and set to true by default.
|
|
*/
|
|
public void setLiftableOverrideEnabled(boolean enabled) {
|
|
this.liftableOverride = enabled;
|
|
}
|
|
|
|
// 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, /* force= */ true);
|
|
}
|
|
|
|
/** Returns whether the {@link AppBarLayout} is in a lifted state or not. */
|
|
public boolean isLifted() {
|
|
return lifted;
|
|
}
|
|
|
|
boolean setLiftedState(boolean lifted) {
|
|
return setLiftedState(lifted, /* force= */ !liftableOverride);
|
|
}
|
|
|
|
// Internal helper method that updates lifted state.
|
|
boolean setLiftedState(boolean lifted, boolean force) {
|
|
if (force && this.lifted != lifted) {
|
|
this.lifted = lifted;
|
|
refreshDrawableState();
|
|
if (liftOnScroll && getBackground() instanceof MaterialShapeDrawable) {
|
|
if (liftOnScrollColor != null) {
|
|
startLiftOnScrollColorAnimation(
|
|
lifted ? 0 : 255, lifted ? 255 : 0);
|
|
} else {
|
|
startLiftOnScrollColorAnimation(
|
|
lifted ? 0 : appBarElevation, lifted ? appBarElevation : 0);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private void startLiftOnScrollColorAnimation(
|
|
float fromValue, float toValue) {
|
|
if (liftOnScrollColorAnimator != null) {
|
|
liftOnScrollColorAnimator.cancel();
|
|
}
|
|
|
|
liftOnScrollColorAnimator = ValueAnimator.ofFloat(fromValue, toValue);
|
|
liftOnScrollColorAnimator.setDuration(liftOnScrollColorDuration);
|
|
liftOnScrollColorAnimator.setInterpolator(liftOnScrollColorInterpolator);
|
|
if (liftOnScrollColorUpdateListener != null) {
|
|
liftOnScrollColorAnimator.addUpdateListener(liftOnScrollColorUpdateListener);
|
|
}
|
|
liftOnScrollColorAnimator.start();
|
|
}
|
|
|
|
/**
|
|
* 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 view that the {@link AppBarLayout} should use to determine whether it should be
|
|
* lifted.
|
|
*/
|
|
public void setLiftOnScrollTargetView(@Nullable View liftOnScrollTargetView) {
|
|
this.liftOnScrollTargetViewId = View.NO_ID;
|
|
if (liftOnScrollTargetView == null) {
|
|
clearLiftOnScrollTargetView();
|
|
} else {
|
|
this.liftOnScrollTargetView = new WeakReference<>(liftOnScrollTargetView);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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(defaultScrollingView);
|
|
if (scrollingView == null) {
|
|
scrollingView = defaultScrollingView;
|
|
}
|
|
return scrollingView != null
|
|
&& (scrollingView.canScrollVertically(-1) || scrollingView.getScrollY() > 0);
|
|
}
|
|
|
|
@Nullable
|
|
private View findLiftOnScrollTargetView(@Nullable View defaultScrollingView) {
|
|
if (liftOnScrollTargetView == null && liftOnScrollTargetViewId != View.NO_ID) {
|
|
View targetView = null;
|
|
if (defaultScrollingView != null) {
|
|
targetView = defaultScrollingView.findViewById(liftOnScrollTargetViewId);
|
|
}
|
|
if (targetView == null && getParent() instanceof ViewGroup) {
|
|
// Assumes the scrolling view is a child of the AppBarLayout's parent,
|
|
// which should be true due to the CoordinatorLayout pattern.
|
|
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;
|
|
updateWillNotDraw();
|
|
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 = 1 << 1;
|
|
|
|
/**
|
|
* 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 = 1 << 2;
|
|
|
|
/**
|
|
* 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 = 1 << 3;
|
|
|
|
/**
|
|
* 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 = 1 << 4;
|
|
|
|
/**
|
|
* 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 = 1 << 5;
|
|
|
|
/** 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;
|
|
|
|
/**
|
|
* No effect should be placed on this view. It will scroll 1:1 with the AppBarLayout/scrolling
|
|
* content.
|
|
*/
|
|
public static final int SCROLL_EFFECT_NONE = 0;
|
|
|
|
/**
|
|
* An effect that will "compress" this view as it hits the scroll ceiling (typically the top of
|
|
* the screen). This is a parallax effect that masks this view and decreases its scroll ratio
|
|
* in relation to the AppBarLayout's offset.
|
|
*/
|
|
public static final int SCROLL_EFFECT_COMPRESS = 1;
|
|
|
|
/**
|
|
* The scroll effect to be applied when the AppBarLayout's offset changes.
|
|
*
|
|
* @hide
|
|
*/
|
|
@RestrictTo(LIBRARY_GROUP)
|
|
@IntDef({SCROLL_EFFECT_NONE, SCROLL_EFFECT_COMPRESS})
|
|
@Retention(RetentionPolicy.SOURCE)
|
|
public @interface ScrollEffect {}
|
|
|
|
private ChildScrollEffect scrollEffect;
|
|
|
|
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);
|
|
|
|
int scrollEffectInt =
|
|
a.getInt(R.styleable.AppBarLayout_Layout_layout_scrollEffect, SCROLL_EFFECT_NONE);
|
|
setScrollEffect(scrollEffectInt);
|
|
|
|
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(@NonNull LayoutParams source) {
|
|
// The copy constructor called here only exists on API 19+.
|
|
super(source);
|
|
scrollFlags = source.scrollFlags;
|
|
scrollEffect = source.scrollEffect;
|
|
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;
|
|
}
|
|
|
|
@Nullable
|
|
private ChildScrollEffect createScrollEffectFromInt(int scrollEffectInt) {
|
|
switch (scrollEffectInt) {
|
|
case SCROLL_EFFECT_COMPRESS:
|
|
return new CompressChildScrollEffect();
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the scroll effect to be applied when the AppBarLayout's offset changes
|
|
*/
|
|
@Nullable
|
|
public ChildScrollEffect getScrollEffect() {
|
|
return scrollEffect;
|
|
}
|
|
|
|
/**
|
|
* Set the scroll effect to be applied when the AppBarLayout's offset changes.
|
|
*
|
|
* @param scrollEffect An {@code AppBarLayoutChildScrollEffect} implementation. If null is
|
|
* passed, the scroll effect will be cleared and no effect will be applied.
|
|
*/
|
|
public void setScrollEffect(@Nullable ChildScrollEffect scrollEffect) {
|
|
this.scrollEffect = scrollEffect;
|
|
}
|
|
|
|
/**
|
|
* Set the scroll effect to be applied when the AppBarLayout's offset changes.
|
|
*
|
|
* @param scrollEffect An {@code AppBarLayoutChildScrollEffect} implementation. If
|
|
* {@link #SCROLL_EFFECT_NONE} is passed, the scroll effect will be cleared and no
|
|
* effect will be applied.
|
|
*/
|
|
public void setScrollEffect(@ScrollEffect int scrollEffect) {
|
|
this.scrollEffect = createScrollEffectFromInt(scrollEffect);
|
|
}
|
|
|
|
/**
|
|
* 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(b/76413401): 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(b/76413401): 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 double EXPAND_BY_KEY_EVENT_THRESHOLD_PERCENTAGE = 0.1;
|
|
|
|
/** Callback to allow control over any {@link AppBarLayout} dragging. */
|
|
// TODO(b/76413401): 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 SavedState savedState;
|
|
|
|
@Nullable private WeakReference<View> lastNestedScrollingChildRef;
|
|
private BaseDragCallback onDragCallback;
|
|
|
|
private boolean coordinatorLayoutA11yScrollable;
|
|
|
|
public BaseBehavior() {}
|
|
|
|
public BaseBehavior(Context context, AttributeSet attrs) {
|
|
super(context, attrs);
|
|
}
|
|
|
|
@Override
|
|
public boolean onStartNestedScroll(
|
|
@NonNull CoordinatorLayout parent,
|
|
@NonNull T child,
|
|
@NonNull 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(
|
|
@NonNull CoordinatorLayout parent, @NonNull T child, @NonNull View directTargetChild) {
|
|
return child.hasScrollableChildren()
|
|
&& parent.getHeight() - directTargetChild.getHeight() <= child.getHeight();
|
|
}
|
|
|
|
@Override
|
|
public void onNestedPreScroll(
|
|
CoordinatorLayout coordinatorLayout,
|
|
@NonNull 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,
|
|
@NonNull 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);
|
|
}
|
|
|
|
if (dyUnconsumed == 0) {
|
|
// The scrolling view may scroll to the top of its content without updating the actions, so
|
|
// update here.
|
|
updateAccessibilityActions(coordinatorLayout, child);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void onStopNestedScroll(
|
|
CoordinatorLayout coordinatorLayout, @NonNull 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,
|
|
@NonNull final T child,
|
|
final int offset,
|
|
float velocity) {
|
|
final int distance = abs(getTopBottomOffsetForScrollingSibling() - offset);
|
|
|
|
final int duration;
|
|
velocity = 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(@NonNull 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(@NonNull 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, @NonNull T abl) {
|
|
final int topInset = abl.getTopInset() + abl.getPaddingTop();
|
|
// The "baseline" of scrolling is the top of the first child. We "add" insets and paddings
|
|
// to the scrolling amount to align offsets and views with the same y-coordinate. (The origin
|
|
// is at the top of the AppBarLayout, so all the coordinates are with negative values.)
|
|
final int offset = getTopBottomOffsetForScrollingSibling() - topInset;
|
|
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 the child is set to fit system windows, its top will include the inset area, we need
|
|
// to minus the inset from snapTop to make the calculation consistent.
|
|
if (offsetChildIndex == 0
|
|
&& ViewCompat.getFitsSystemWindows(abl)
|
|
&& ViewCompat.getFitsSystemWindows(offsetChild)) {
|
|
snapTop -= 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;
|
|
}
|
|
|
|
// Excludes insets and paddings from the offset. (Offsets use the top of child views as
|
|
// the origin.)
|
|
final int newOffset = calculateSnapOffset(offset, snapBottom, snapTop) + topInset;
|
|
animateOffsetTo(
|
|
coordinatorLayout, abl, clamp(newOffset, -abl.getTotalScrollRange(), 0), 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
private int calculateSnapOffset(int value, int bottom, int top) {
|
|
return value < (bottom + top) / 2 ? bottom : top;
|
|
}
|
|
|
|
private static boolean checkFlag(final int flags, final int check) {
|
|
return (flags & check) == check;
|
|
}
|
|
|
|
@Override
|
|
public boolean onMeasureChild(
|
|
@NonNull CoordinatorLayout parent,
|
|
@NonNull 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(
|
|
@NonNull CoordinatorLayout parent, @NonNull 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 (savedState != null && (pendingAction & PENDING_ACTION_FORCE) == 0) {
|
|
if (savedState.fullyScrolled) {
|
|
// Keep fully scrolled.
|
|
setHeaderTopBottomOffset(parent, abl, -abl.getTotalScrollRange());
|
|
} else if (savedState.fullyExpanded) {
|
|
// Keep fully expanded.
|
|
setHeaderTopBottomOffset(parent, abl, 0);
|
|
} else {
|
|
// Not fully scrolled, restore the visible percetage of child layout.
|
|
View child = abl.getChildAt(savedState.firstVisibleChildIndex);
|
|
int offset = -child.getBottom();
|
|
if (savedState.firstVisibleChildAtMinimumHeight) {
|
|
offset += ViewCompat.getMinimumHeight(child) + abl.getTopInset();
|
|
} else {
|
|
offset += Math.round(child.getHeight() * savedState.firstVisibleChildPercentageShown);
|
|
}
|
|
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();
|
|
savedState = null;
|
|
|
|
// 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(
|
|
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.onOffsetChanged(getTopAndBottomOffset());
|
|
|
|
updateAccessibilityActions(parent, abl);
|
|
// TODO(b/243555083): Until CoordinatorLayout fixes triggering scroll events with physical
|
|
// keyboard scrolling, we have this hack in place.
|
|
View v = findFirstScrollingChild(parent);
|
|
if (v != null) {
|
|
if (VERSION.SDK_INT >= VERSION_CODES.P) {
|
|
v.addOnUnhandledKeyEventListener(
|
|
(v1, event) -> {
|
|
controlExpansionOnKeyPress(event, v, abl);
|
|
return false;
|
|
});
|
|
} else {
|
|
// Unfortunately if not using >= API 28, we don't have access to the unhandled key event
|
|
// handler. Using setOnKeyListener is less ideal since it will replace any listener
|
|
// already on the scrollable child. Furthermore, the 'scrolling' may be occurring due to
|
|
// switching focus between children of the scrollable child, which will not trigger this
|
|
// listener.
|
|
v.setOnKeyListener(
|
|
(v1, keyCode, event) -> {
|
|
controlExpansionOnKeyPress(event, v, abl);
|
|
return false;
|
|
});
|
|
}
|
|
}
|
|
return handled;
|
|
}
|
|
|
|
// TODO(b/243555083): Until CoordinatorLayout fixes triggering scroll events with physical
|
|
// keyboard scrolling, we have this hack in place.
|
|
private void controlExpansionOnKeyPress(
|
|
KeyEvent event, View scrollableChild, AppBarLayout abl) {
|
|
if (event.getAction() == KeyEvent.ACTION_DOWN || event.getAction() == KeyEvent.ACTION_UP) {
|
|
int keyCode = event.getKeyCode();
|
|
if (keyCode == KeyEvent.KEYCODE_DPAD_UP
|
|
|| keyCode == KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP
|
|
|| keyCode == KeyEvent.KEYCODE_PAGE_UP) {
|
|
// If within height threshold, we expand.
|
|
if (scrollableChild.getScrollY()
|
|
< scrollableChild.getMeasuredHeight() * EXPAND_BY_KEY_EVENT_THRESHOLD_PERCENTAGE) {
|
|
abl.setExpanded(true);
|
|
}
|
|
} else if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN
|
|
|| keyCode == KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN
|
|
|| keyCode == KeyEvent.KEYCODE_PAGE_DOWN) {
|
|
if (scrollableChild.getScrollY() > 0) {
|
|
abl.setExpanded(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void updateAccessibilityActions(
|
|
CoordinatorLayout coordinatorLayout, @NonNull T appBarLayout) {
|
|
ViewCompat.removeAccessibilityAction(coordinatorLayout, ACTION_SCROLL_FORWARD.getId());
|
|
ViewCompat.removeAccessibilityAction(coordinatorLayout, ACTION_SCROLL_BACKWARD.getId());
|
|
// Don't add a11y actions if the abl has no scroll range.
|
|
if (appBarLayout.getTotalScrollRange() == 0) {
|
|
return;
|
|
}
|
|
// Don't add actions if a child view doesn't have the behavior that will cause the abl to
|
|
// scroll.
|
|
View scrollingView = getChildWithScrollingBehavior(coordinatorLayout);
|
|
if (scrollingView == null) {
|
|
return;
|
|
}
|
|
|
|
// Don't add actions if the children do not have scrolling flags.
|
|
if (!childrenHaveScrollFlags(appBarLayout)) {
|
|
return;
|
|
}
|
|
|
|
if (!ViewCompat.hasAccessibilityDelegate(coordinatorLayout)) {
|
|
ViewCompat.setAccessibilityDelegate(
|
|
coordinatorLayout,
|
|
new AccessibilityDelegateCompat() {
|
|
@Override
|
|
public void onInitializeAccessibilityNodeInfo(
|
|
View host, @NonNull AccessibilityNodeInfoCompat info) {
|
|
super.onInitializeAccessibilityNodeInfo(host, info);
|
|
info.setScrollable(coordinatorLayoutA11yScrollable);
|
|
info.setClassName(ScrollView.class.getName());
|
|
}
|
|
});
|
|
}
|
|
|
|
coordinatorLayoutA11yScrollable =
|
|
addAccessibilityScrollActions(coordinatorLayout, appBarLayout, scrollingView);
|
|
}
|
|
|
|
@Nullable
|
|
private View getChildWithScrollingBehavior(CoordinatorLayout coordinatorLayout) {
|
|
final int childCount = coordinatorLayout.getChildCount();
|
|
for (int i = 0; i < childCount; i++) {
|
|
final View child = coordinatorLayout.getChildAt(i);
|
|
|
|
CoordinatorLayout.LayoutParams lp =
|
|
(CoordinatorLayout.LayoutParams) child.getLayoutParams();
|
|
if (lp.getBehavior() instanceof ScrollingViewBehavior) {
|
|
return child;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private boolean childrenHaveScrollFlags(AppBarLayout appBarLayout) {
|
|
final int childCount = appBarLayout.getChildCount();
|
|
for (int i = 0; i < childCount; i++) {
|
|
final View child = appBarLayout.getChildAt(i);
|
|
final LayoutParams childLp = (LayoutParams) child.getLayoutParams();
|
|
final int flags = childLp.scrollFlags;
|
|
|
|
if (flags != AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private boolean addAccessibilityScrollActions(
|
|
final CoordinatorLayout coordinatorLayout,
|
|
@NonNull final T appBarLayout,
|
|
@NonNull final View scrollingView) {
|
|
boolean a11yScrollable = false;
|
|
if (getTopBottomOffsetForScrollingSibling() != -appBarLayout.getTotalScrollRange()) {
|
|
// Add a collapsing action if the view offset isn't the abl scroll range.
|
|
// (The same offset means the view is completely collapsed). Collapse to minimum height.
|
|
addActionToExpand(coordinatorLayout, appBarLayout, ACTION_SCROLL_FORWARD, false);
|
|
a11yScrollable = true;
|
|
}
|
|
// Don't add an expanding action if the sibling offset is 0, which would mean the abl is
|
|
// completely expanded.
|
|
if (getTopBottomOffsetForScrollingSibling() != 0) {
|
|
if (scrollingView.canScrollVertically(-1)) {
|
|
// Expanding action. If the view can scroll down, expand the app bar reflecting the logic
|
|
// in onNestedPreScroll.
|
|
final int dy = -appBarLayout.getDownNestedPreScrollRange();
|
|
// Offset by non-zero.
|
|
if (dy != 0) {
|
|
ViewCompat.replaceAccessibilityAction(
|
|
coordinatorLayout,
|
|
ACTION_SCROLL_BACKWARD,
|
|
null,
|
|
new AccessibilityViewCommand() {
|
|
@Override
|
|
public boolean perform(@NonNull View view, @Nullable CommandArguments arguments) {
|
|
onNestedPreScroll(
|
|
coordinatorLayout,
|
|
appBarLayout,
|
|
scrollingView,
|
|
0,
|
|
dy,
|
|
new int[] {0, 0},
|
|
ViewCompat.TYPE_NON_TOUCH);
|
|
return true;
|
|
}
|
|
});
|
|
a11yScrollable = true;
|
|
}
|
|
} else {
|
|
// If the view can't scroll down, we are probably at the top of the scrolling content so
|
|
// expand completely.
|
|
addActionToExpand(coordinatorLayout, appBarLayout, ACTION_SCROLL_BACKWARD, true);
|
|
a11yScrollable = true;
|
|
}
|
|
}
|
|
return a11yScrollable;
|
|
}
|
|
|
|
private void addActionToExpand(
|
|
CoordinatorLayout parent,
|
|
@NonNull final T appBarLayout,
|
|
@NonNull AccessibilityActionCompat action,
|
|
final boolean expand) {
|
|
ViewCompat.replaceAccessibilityAction(
|
|
parent,
|
|
action,
|
|
null,
|
|
new AccessibilityViewCommand() {
|
|
@Override
|
|
public boolean perform(@NonNull View view, @Nullable CommandArguments arguments) {
|
|
appBarLayout.setExpanded(expand);
|
|
return true;
|
|
}
|
|
});
|
|
}
|
|
|
|
@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(@NonNull CoordinatorLayout parent, @NonNull 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(@NonNull T view) {
|
|
return -view.getDownNestedScrollRange();
|
|
}
|
|
|
|
@Override
|
|
int getScrollRangeForDragFling(@NonNull T view) {
|
|
return view.getTotalScrollRange();
|
|
}
|
|
|
|
@Override
|
|
int setHeaderTopBottomOffset(
|
|
@NonNull CoordinatorLayout coordinatorLayout,
|
|
@NonNull 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 = 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) {
|
|
// If the offset has changed, pass the change to any child scroll effect.
|
|
for (int i = 0; i < appBarLayout.getChildCount(); i++) {
|
|
LayoutParams params = (LayoutParams) appBarLayout.getChildAt(i).getLayoutParams();
|
|
ChildScrollEffect scrollEffect = params.getScrollEffect();
|
|
if (scrollEffect != null
|
|
&& (params.getScrollFlags() & LayoutParams.SCROLL_FLAG_SCROLL) != 0) {
|
|
scrollEffect.onOffsetChanged(
|
|
appBarLayout, appBarLayout.getChildAt(i), getTopAndBottomOffset());
|
|
}
|
|
}
|
|
}
|
|
|
|
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.onOffsetChanged(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;
|
|
}
|
|
|
|
updateAccessibilityActions(coordinatorLayout, appBarLayout);
|
|
return consumed;
|
|
}
|
|
|
|
@VisibleForTesting
|
|
boolean isOffsetAnimatorRunning() {
|
|
return offsetAnimator != null && offsetAnimator.isRunning();
|
|
}
|
|
|
|
private int interpolateOffset(@NonNull T layout, final int offset) {
|
|
final int absOffset = 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(
|
|
@NonNull final CoordinatorLayout parent,
|
|
@NonNull final T layout,
|
|
final int offset,
|
|
final int direction,
|
|
final boolean forceJump) {
|
|
final View child = getAppBarChildOnOffset(layout, offset);
|
|
boolean lifted = false;
|
|
if (child != null) {
|
|
final AppBarLayout.LayoutParams childLp = (LayoutParams) child.getLayoutParams();
|
|
final int flags = childLp.getScrollFlags();
|
|
|
|
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 (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(@NonNull CoordinatorLayout parent, @NonNull 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;
|
|
}
|
|
|
|
@Nullable
|
|
private static View getAppBarChildOnOffset(
|
|
@NonNull final AppBarLayout layout, final int offset) {
|
|
final int absOffset = 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(@NonNull 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(@NonNull CoordinatorLayout parent, @NonNull T abl) {
|
|
Parcelable superState = super.onSaveInstanceState(parent, abl);
|
|
SavedState scrollState = saveScrollState(superState, abl);
|
|
return scrollState == null ? superState : scrollState;
|
|
}
|
|
|
|
@Override
|
|
public void onRestoreInstanceState(
|
|
@NonNull CoordinatorLayout parent, @NonNull T appBarLayout, Parcelable state) {
|
|
if (state instanceof SavedState) {
|
|
restoreScrollState((SavedState) state, true);
|
|
super.onRestoreInstanceState(parent, appBarLayout, savedState.getSuperState());
|
|
} else {
|
|
super.onRestoreInstanceState(parent, appBarLayout, state);
|
|
savedState = null;
|
|
}
|
|
}
|
|
|
|
@Nullable
|
|
SavedState saveScrollState(@Nullable Parcelable superState, @NonNull T 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 == null ? AbsSavedState.EMPTY_STATE : superState);
|
|
ss.fullyExpanded = offset == 0;
|
|
ss.fullyScrolled = !ss.fullyExpanded && -offset >= abl.getTotalScrollRange();
|
|
ss.firstVisibleChildIndex = i;
|
|
ss.firstVisibleChildAtMinimumHeight =
|
|
visBottom == (ViewCompat.getMinimumHeight(child) + abl.getTopInset());
|
|
ss.firstVisibleChildPercentageShown = visBottom / (float) child.getHeight();
|
|
return ss;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
void restoreScrollState(@Nullable SavedState state, boolean force) {
|
|
if (savedState == null || force) {
|
|
savedState = state;
|
|
}
|
|
}
|
|
|
|
/** A {@link Parcelable} implementation for {@link AppBarLayout}. */
|
|
protected static class SavedState extends AbsSavedState {
|
|
boolean fullyScrolled;
|
|
boolean fullyExpanded;
|
|
int firstVisibleChildIndex;
|
|
float firstVisibleChildPercentageShown;
|
|
boolean firstVisibleChildAtMinimumHeight;
|
|
|
|
public SavedState(@NonNull Parcel source, ClassLoader loader) {
|
|
super(source, loader);
|
|
fullyScrolled = source.readByte() != 0;
|
|
fullyExpanded = source.readByte() != 0;
|
|
firstVisibleChildIndex = source.readInt();
|
|
firstVisibleChildPercentageShown = source.readFloat();
|
|
firstVisibleChildAtMinimumHeight = source.readByte() != 0;
|
|
}
|
|
|
|
public SavedState(Parcelable superState) {
|
|
super(superState);
|
|
}
|
|
|
|
@Override
|
|
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
|
super.writeToParcel(dest, flags);
|
|
dest.writeByte((byte) (fullyScrolled ? 1 : 0));
|
|
dest.writeByte((byte) (fullyExpanded ? 1 : 0));
|
|
dest.writeInt(firstVisibleChildIndex);
|
|
dest.writeFloat(firstVisibleChildPercentageShown);
|
|
dest.writeByte((byte) (firstVisibleChildAtMinimumHeight ? 1 : 0));
|
|
}
|
|
|
|
public static final Creator<SavedState> CREATOR =
|
|
new ClassLoaderCreator<SavedState>() {
|
|
@NonNull
|
|
@Override
|
|
public SavedState createFromParcel(@NonNull Parcel source, ClassLoader loader) {
|
|
return new SavedState(source, loader);
|
|
}
|
|
|
|
@Nullable
|
|
@Override
|
|
public SavedState createFromParcel(@NonNull Parcel source) {
|
|
return new SavedState(source, null);
|
|
}
|
|
|
|
@NonNull
|
|
@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(
|
|
@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) {
|
|
offsetChildAsNeeded(child, dependency);
|
|
updateLiftedStateIfNeeded(child, dependency);
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public void onDependentViewRemoved(
|
|
@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) {
|
|
if (dependency instanceof AppBarLayout) {
|
|
ViewCompat.removeAccessibilityAction(parent, ACTION_SCROLL_FORWARD.getId());
|
|
ViewCompat.removeAccessibilityAction(parent, ACTION_SCROLL_BACKWARD.getId());
|
|
ViewCompat.setAccessibilityDelegate(parent, null);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public boolean onRequestChildRectangleOnScreen(
|
|
@NonNull CoordinatorLayout parent,
|
|
@NonNull View child,
|
|
@NonNull Rect rectangle,
|
|
boolean immediate) {
|
|
final AppBarLayout header = findFirstDependency(parent.getDependencies(child));
|
|
if (header != null) {
|
|
// Offset the rect by the child's left/top
|
|
Rect offsetRect = new Rect(rectangle);
|
|
offsetRect.offset(child.getLeft(), child.getTop());
|
|
|
|
final Rect parentRect = tempRect1;
|
|
parentRect.set(0, 0, parent.getWidth(), parent.getHeight());
|
|
|
|
if (!parentRect.contains(offsetRect)) {
|
|
// 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(@NonNull View child, @NonNull 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(@NonNull AppBarLayout abl) {
|
|
final CoordinatorLayout.Behavior behavior =
|
|
((CoordinatorLayout.LayoutParams) abl.getLayoutParams()).getBehavior();
|
|
if (behavior instanceof BaseBehavior) {
|
|
return ((BaseBehavior) behavior).getTopBottomOffsetForScrollingSibling();
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
@Nullable
|
|
@Override
|
|
AppBarLayout findFirstDependency(@NonNull 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));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An effect class that should be implemented and used by AppBarLayout children to be given
|
|
* effects when the AppBarLayout's offset changes.
|
|
*/
|
|
public abstract static class ChildScrollEffect {
|
|
|
|
/**
|
|
* Called each time the AppBarLayout's offset changes. Update the {@code child} with any desired
|
|
* effects.
|
|
*
|
|
* @param appBarLayout The parent AppBarLayout
|
|
* @param child The View to be given any desired effect
|
|
*/
|
|
public abstract void onOffsetChanged(
|
|
@NonNull AppBarLayout appBarLayout, @NonNull View child, float offset);
|
|
}
|
|
|
|
/**
|
|
* A class which handles updating an AppBarLayout child, if marked with the {@code
|
|
* app:layout_scrollEffect} {@code compress}, at each step in the {@code AppBarLayout}'s offset
|
|
* animation.
|
|
*
|
|
* <p>Only a single {@code AppBarLayout} child should be given a compress effect.
|
|
*/
|
|
public static class CompressChildScrollEffect extends ChildScrollEffect {
|
|
|
|
// The factor of the child's height by which this child will scroll during compression. Setting
|
|
// this to 0 would keep the child in place and look like the AppBarLayout simply masks the view
|
|
// without offsetting it at all. Setting this to 1 would scroll the child up with the ABL plus
|
|
// translate the child up by its full height. A negative value will translate the child down.
|
|
private static final float COMPRESS_DISTANCE_FACTOR = .3f;
|
|
|
|
private final Rect relativeRect = new Rect();
|
|
private final Rect ghostRect = new Rect();
|
|
|
|
private static void updateRelativeRect(Rect rect, AppBarLayout appBarLayout, View child) {
|
|
child.getDrawingRect(rect);
|
|
// Get the child's rect relative to its parent ABL
|
|
appBarLayout.offsetDescendantRectToMyCoords(child, rect);
|
|
rect.offset(0, -appBarLayout.getTopInset());
|
|
}
|
|
|
|
@Override
|
|
public void onOffsetChanged(
|
|
@NonNull AppBarLayout appBarLayout, @NonNull View child, float offset) {
|
|
updateRelativeRect(relativeRect, appBarLayout, child);
|
|
float distanceFromCeiling = relativeRect.top - abs(offset);
|
|
// If the view is at the ceiling, begin the compress animation.
|
|
if (distanceFromCeiling <= 0F) {
|
|
// The "compressed" progress. When p = 0, the top of the child is at the top of the ceiling
|
|
// (uncompressed). When p = 1, the bottom of the child is at the top of the ceiling
|
|
// (fully compressed).
|
|
float p = clamp(abs(distanceFromCeiling / relativeRect.height()), 0f, 1f);
|
|
|
|
// Set offsetY to the full distance from ceiling to keep the child exactly in place.
|
|
float offsetY = -distanceFromCeiling;
|
|
|
|
// Decrease the offsetY so the child moves with the app bar parent. Here, it will move a
|
|
// total of the child's height times the compress distance factor but will do so with an
|
|
// eased-out value - moving at a near 1:1 speed with the app bar at first and slowing down
|
|
// as it approaches the ceiling (p = 1).
|
|
float easeOutQuad = 1F - (1F - p) * (1F - p);
|
|
float distance = relativeRect.height() * COMPRESS_DISTANCE_FACTOR;
|
|
offsetY -= distance * easeOutQuad;
|
|
|
|
// Translate the view to create a parallax effect, letting the ghost clip when out of
|
|
// bounds.
|
|
child.setTranslationY(offsetY);
|
|
|
|
// Use a rect to clip the child by its original bounds before it is given a
|
|
// translation (compress effect). This masks and ensures the child doesn't overlap other
|
|
// children inside the ABL.
|
|
child.getDrawingRect(ghostRect);
|
|
ghostRect.offset(0, (int) -offsetY);
|
|
ViewCompat.setClipBounds(child, ghostRect);
|
|
} else {
|
|
// Reset both the clip bounds and translationY of this view
|
|
ViewCompat.setClipBounds(child, null);
|
|
child.setTranslationY(0);
|
|
}
|
|
}
|
|
}
|
|
}
|