/* * Copyright 2017 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.backlayer; import android.content.Context; import android.os.Parcelable; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import com.google.android.material.expandable.ExpandableWidget; import com.google.android.material.expandable.ExpandableWidgetHelper; import com.google.android.material.stateful.ExtendableSavedState; import android.support.design.widget.CoordinatorLayout; import android.support.v4.view.ViewCompat; import android.util.AttributeSet; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; /** * BackLayerLayout implements the Material back layer concept, and can be used to implement * navigation drawers and other surfaces. * *

The back layer concept represents a background layer overlapped by a foreground layer. When * the background layer is expanded to show additional content (usually as a result of user * interaction), it pushes the foreground layer partially off-screen. * *

This view depends heavily on being used as a direct child within a {@link CoordinatorLayout}. * *

Notice BackLayerLayout is a LinearLayout, so you need to make sure you're using the correct * orientation that matches the position you've chosen for the back layer (i.e. use {@code * android:orientation="vertical"} in conjunction with {@code android:gravity="top"} or {@code * android:gravity="bottom"}). * *

Usage guide: * *

* *
{@code
 * 
 *   
 *     
 *       
 *     
 *     
 *     
 *   
 *   
 * 
 * }
* * The reason you need to specify both {@code android:gravity} and {@code android:layout_gravity} * and they must match is that they are used for different purposes: * * */ // Implementation detail ahead, since it's not relevant to the user this has been pulled out of the // Javadoc: // Considering the usages for gravity and layout_gravity spelled out above, and that both values // must match in order for the BackLayerLayout to work correctly, we thought of ways to depend only // on one of those two values. We decided to attempt to remove the dependency on layout_gravity for // the following reasons: // 1. The way we use gravity to have the contents of the back layer gravitate to the correct edge is // actually implemented in LinearLayout and the dependence on gravity is deeply ingrained in this // code, it would be prohibitively hard to rework layout_gravity for this purpose. // 2. It is not recommended for widgets themselves to depend on LayoutParams, and we would only // worsen the situation adding another dependency on layout_gravity. While it is true that // BackLayerLayout is only supported while used inside a CoordinatorLayout and it is already tightly // coupled to it, it seems backwards to try to retrofit this into code that comes from the // superclass. // // When trying to depend only on gravity we found the following two issues: // 1. LinearLayout does not expose getGravity() prior to API 24, that is solvable through // reflection (and it would likely work in all devices though that is not guaranteed). // 2. LinearLayout does not just take an edge gravity (like top, left, right....), if it is an edge // gravity it forces it to become a corner gravity (top|start, for example). This is problematic // because BackLayerLayout and BackLayerSiblingBehavior constantly check the edge gravity to do the // following: // - Measure the expanded content of the back layer. // - Measure and layout the content layer. // - Slide the content layer out of view when the back layer is expanded. // All of these operations depend on having an edge gravity instead of a corner gravity, so short of // rewriting the relevant parts of LinearLayout in BackLayerLayout, using the same gravity value // that LinearLayout depends on is not an option for BackLayerLayout and BackLayerSiblingBehavior. public class BackLayerLayout extends LinearLayout implements ExpandableWidget { private int expandedHeight; private int expandedWidth; private boolean expandedSizeMeasured = false; private boolean originalMeasureSpecsSaved = false; private int originalHeightMeasureSpec; private int originalWidthMeasureSpec; private ChildViewAccessibilityHelper childViewAccessibilityHelper; private final List callbacks = new CopyOnWriteArrayList<>(); private final ExpandableWidgetHelper expandableWidgetHelper = new ExpandableWidgetHelper(this); public BackLayerLayout(@NonNull Context context) { super(context); } public BackLayerLayout(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); } /** Add a new {@link BackLayerCallback} to listen to back layer events. */ public void addBackLayerCallback(BackLayerCallback callback) { if (!callbacks.contains(callback)) { callbacks.add(callback); } } /** * Expands or collapses the back layer. * *

Notice that this method does not automatically change visibility on child views of the back * layer, the developer has to prepare the contents of the back layer either before calling this * method or in {@link BackLayerCallback#onBeforeExpand()} or {@link * BackLayerCallback#onBeforeCollapse()}. */ @Override public boolean setExpanded(boolean expanded) { return expandableWidgetHelper.setExpanded(expanded); } @Override public boolean isExpanded() { return expandableWidgetHelper.isExpanded(); } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); childViewAccessibilityHelper = new ChildViewAccessibilityHelper(this); setOnHierarchyChangeListener(childViewAccessibilityHelper); childViewAccessibilityHelper.disableChildFocus(); } @Override public void requestLayout() { super.requestLayout(); expandedSizeMeasured = false; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (!originalMeasureSpecsSaved) { originalWidthMeasureSpec = widthMeasureSpec; originalHeightMeasureSpec = heightMeasureSpec; originalMeasureSpecsSaved = true; } // Measure the minimum size only if it's not previously set, for example in XML layout. if (ViewCompat.getMinimumHeight(this) == 0 && ViewCompat.getMinimumWidth(this) == 0) { // Find the CollapsedBackLayerContents boolean foundCollapsed = false; for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); if (child instanceof CollapsedBackLayerContents) { if (foundCollapsed) { throw new IllegalStateException( "More than one CollapsedBackLayerContents found inside BackLayerLayout"); } foundCollapsed = true; LinearLayout.LayoutParams childLayoutParams = (LinearLayout.LayoutParams) child.getLayoutParams(); child.measure(childLayoutParams.width, childLayoutParams.height); setMinimumHeight( child.getMeasuredHeight() + childLayoutParams.bottomMargin + childLayoutParams.topMargin); setMinimumWidth( child.getMeasuredWidth() + childLayoutParams.leftMargin + childLayoutParams.rightMargin); } } if (!foundCollapsed) { throw new IllegalStateException( "No CollapsedBackLayerContents found inside BackLayerLayout"); } } super.onMeasure(widthMeasureSpec, heightMeasureSpec); } /** * Call this to measure the BackLayerLayout's expanded size on-demand. This must be called before * {@link #calculateExpandedWidth()} and {@link #calculateExpandedHeight()} are queried. */ private void remeasureExpandedSize() { if (expandedSizeMeasured) { return; } CoordinatorLayout.LayoutParams layoutParams = (CoordinatorLayout.LayoutParams) getLayoutParams(); final int absoluteGravity = Gravity.getAbsoluteGravity(layoutParams.gravity, ViewCompat.getLayoutDirection(this)); int heightMeasureSpec = originalHeightMeasureSpec; int widthMeasureSpec = originalWidthMeasureSpec; // In order to know the measurements for a expanded version of the back layer we need to // measure the back layer with one dimension set to MeasureSpec.UNSPECIFIED instead of the // setting // that came in the original MeasureSpec (MeasureSpec.EXACTLY, since the BackLayerLayout must // use match_parent for both dimensions). // // While it would seem natural to use MeasureSpec.AT_MOST, this method can be called from // onRestoreInstanceState(Parcelable) which would happen before the first measure pass, and thus // the original measure specs would be 0, causing a wrong measurement. switch (absoluteGravity) { case Gravity.LEFT: case Gravity.RIGHT: widthMeasureSpec = MeasureSpec.makeMeasureSpec( MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.UNSPECIFIED); break; case Gravity.TOP: case Gravity.BOTTOM: int size = MeasureSpec.getSize(heightMeasureSpec); heightMeasureSpec = MeasureSpec.makeMeasureSpec(size, MeasureSpec.UNSPECIFIED); break; default: break; } measure(widthMeasureSpec, heightMeasureSpec); expandedHeight = getMeasuredHeight(); expandedWidth = getMeasuredWidth(); // Recalculate with the original measure specs, so it fits the entire coordinator layout. measure(originalWidthMeasureSpec, originalHeightMeasureSpec); expandedSizeMeasured = true; } @Override public Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); ExtendableSavedState state = new ExtendableSavedState(superState); state.extendableStates.put( "expandableWidgetHelper", expandableWidgetHelper.onSaveInstanceState()); return state; } @Override public void onRestoreInstanceState(Parcelable state) { if (!(state instanceof ExtendableSavedState)) { super.onRestoreInstanceState(state); return; } ExtendableSavedState ess = (ExtendableSavedState) state; super.onRestoreInstanceState(ess.getSuperState()); expandableWidgetHelper.onRestoreInstanceState( ess.extendableStates.get("expandableWidgetHelper")); } void onExpandAnimationStart() { for (BackLayerCallback callback : callbacks) { callback.onBeforeExpand(); } } void onExpandAnimationEnd() { for (BackLayerCallback callback : callbacks) { callback.onAfterExpand(); } } void onCollapseAnimationStart() { for (BackLayerCallback callback : callbacks) { callback.onBeforeCollapse(); } } void onCollapseAnimationEnd() { childViewAccessibilityHelper.disableChildFocus(); for (BackLayerCallback callback : callbacks) { callback.onAfterCollapse(); } } /** The measured height for the expanded version of the back layer. */ int calculateExpandedHeight() { if (!expandedSizeMeasured) { remeasureExpandedSize(); } return expandedHeight; } /** The measured width for the expanded version of the back layer. */ int calculateExpandedWidth() { if (!expandedSizeMeasured) { remeasureExpandedSize(); } return expandedWidth; } }