travisc 0f29cbf042 Make widget its own package, change lib into a pure export package.
This also moves expandable and transformation into their own packages, as was originally intended (but they had resources, which was problematic at that time). CoordinatorLayout has been moved out of the typical source root to ease its later deletion (when it has made it into core-ui).

PiperOrigin-RevId: 180728823
2018-01-11 11:08:30 -05:00

373 lines
16 KiB
Java

/*
* 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 android.support.design.backlayer;
import android.content.Context;
import android.os.Parcelable;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.design.expandable.ExpandableWidget;
import android.support.design.expandable.ExpandableWidgetHelper;
import android.support.design.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.
*
* <p>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.
*
* <p>This view depends heavily on being used as a direct child within a {@link CoordinatorLayout}.
*
* <p>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"}).
*
* <p><b>Usage guide:</b>
*
* <ul>
* <li>There has to be <b>exactly ONE</b> other direct child of the same CoordinatorLayout that
* uses {@link BackLayerSiblingBehavior} as its behavior (set {@code
* app:layout_behavior="@string/design_backlayer_sibling_behavior"}). This is the content
* layer. Clicks on the content layer while the back layer is exposed will cause the back
* layer to collapse.
* <li>The {@code BackLayerLayout} can contain an arbitrary number of subviews, however <b>exactly
* ONE</b> of them must be a {@link CollapsedBackLayerContents}, anything inside this view
* will be considered the contents of the back layer that will always be visible. All other
* views will be extra content under the content layer. You can support multiple experiences
* under the back layer by changing the visibility or swapping out these other views.
* <li>You must use match_parent for the {@code BackLayerLayout}'s width and height.
* <li>Set both {@code android:gravity} and {@code android:layout_gravity} for the {@code
* BackLayerLayout} to the same value. This value is the edge to which the back layer is
* attached and can be any of {@code left}, {@code start}, {@code left|start}, {@code top},
* {@code right}, {@code right|end}, {@code end}, {@code bottom}.
* <li>Set {@code BackLayerLayout}'s {@code android:orientation} to {@code vertical} or {@code
* horizontal} matching the gravity ({@code vertical} for gravities {@code top} or {@code
* bottom}, otherwise use {@code horizontal}).
* <li>Add UI elements and behavior to expose the back layer. {@code BackLayerLayout} does not try
* to be smart about when to expand, so you must add UI to expand the back layer (using an
* OnClickListener on a button, for example). {@code BackLayerLayout} offers a {@link
* #setExpanded(boolean)} method that you can call in response to clicks or other events.
* <li>Add {@link BackLayerCallback}s using {@link #addBackLayerCallback(BackLayerCallback)} in
* order to listen to changes in the back layer's status. This also may be useful if your back
* layer needs extra animations, you could use {@link BackLayerCallback#onBeforeExpand()} and
* {@link BackLayerCallback#onBeforeCollapse()} for this purpose.
* <li>If you {@link BackLayerCallback} at all you probably need to implement{@link
* BackLayerCallback#onRestoringExpandedBackLayer()}. This method must not use any animations
* while replicating the effects of calling {@link BackLayerCallback#onBeforeExpand()}
* followed by {@link BackLayerCallback#onAfterExpand()}. When restoring the expanded status
* on activity restarts, no animation will be used and thus {@link
* BackLayerCallback#onBeforeExpand()} and {@link BackLayerCallback#onAfterExpand()} will not
* be called.
* <li>You MUST NOT use a {@link ViewGroup.OnHierarchyChangeListener} on the back layer as it is
* used for internal housekeeping.
* </ul>
*
* <pre>{@code
* <CoordinatorLayout ...>
* <BackLayerLayout
* android:layout_width="match_parent"
* android:layout_height="match_parent"
* android:layout_gravity="top"
* android:gravity="top"
* android:orientation="vertical">
* <CollapsedBackLayerContents
* android:layout_width="match_parent"
* android:layout_height="wrap_content">
* <include layout="@layout/always_visible_content"/>
* </CollapsedBackLayerContents>
* <include layout="@layout/default_content_hidden_behind_content_layer"/>
* <include
* layout="@layout/secondary_content_hidden_behind_content_layer"
* android:visibility="GONE"/>
* </BackLayerLayout>
* <YourContentLayerView
* android:layout_width="match_parent"
* android:layout_height="match_parent"
* app:layout_behavior="@string/design_backlayer_sibling_behavior"/>
* </CoordinatorLayout>
* }</pre>
*
* 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:
*
* <ul>
* <li>{@code layout_gravity} is used to specify to the {@link BackLayerSiblingBehavior} what edge
* the back layer is anchored to. {@code layout_gravity} is used by {@code BackLayerLayout} to
* corectly measure its expanded state (setting the moving dimension's {@link MeasureSpec} to
* {@link MeasureSpec#AT_MOST}). {@code layout_gravity} is also used by the {@link
* BackLayerSiblingBehavior} to measure and lay out the content layer view to cover the area
* of the back layer that does not contain the {@link CollapsedBackLayerContents} (when the
* back layer is collapsed).
* <li>{@code gravity} is used to have the contents of the back layer gravitate to the same edge,
* see {@link LinearLayout#setGravity(int)} for more information on this.
* </ul>
*/
// 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<BackLayerCallback> 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.
*
* <p>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;
}
}