From 08c23dc384f4010509f1142b8931b716df07caa9 Mon Sep 17 00:00:00 2001 From: rightnao Date: Fri, 25 Oct 2024 18:08:04 +0000 Subject: [PATCH] [NavigationRail] Navigation rail expansion PiperOrigin-RevId: 689848271 --- .../NavigationRailSubMenuDemoFragment.java | 39 ++++++ .../cat_navigation_rail_efab_header_view.xml | 39 ++++++ .../cat_navigation_rail_submenus_fragment.xml | 32 +++-- .../menu/navigation_rail_submenus_menu.xml | 22 +++ .../navigationrail/res/values/strings.xml | 15 +++ .../ExtendedFloatingActionButton.java | 3 - .../navigation/NavigationBarItemView.java | 24 +++- .../navigationrail/LabelMoveTransition.java | 71 ++++++++++ .../navigationrail/NavigationRailView.java | 126 +++++++++++++++++- 9 files changed, 351 insertions(+), 20 deletions(-) create mode 100644 catalog/java/io/material/catalog/navigationrail/res/layout/cat_navigation_rail_efab_header_view.xml create mode 100644 lib/java/com/google/android/material/navigationrail/LabelMoveTransition.java diff --git a/catalog/java/io/material/catalog/navigationrail/NavigationRailSubMenuDemoFragment.java b/catalog/java/io/material/catalog/navigationrail/NavigationRailSubMenuDemoFragment.java index 49fb40ec4f3..89a85d3b4bf 100644 --- a/catalog/java/io/material/catalog/navigationrail/NavigationRailSubMenuDemoFragment.java +++ b/catalog/java/io/material/catalog/navigationrail/NavigationRailSubMenuDemoFragment.java @@ -19,12 +19,18 @@ import io.material.catalog.R; import android.os.Bundle; +import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.FrameLayout.LayoutParams; +import android.widget.ImageView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton; import com.google.android.material.navigationrail.NavigationRailView; +import com.google.android.material.snackbar.Snackbar; import io.material.catalog.feature.DemoFragment; /** A base class that provides a demo screen structure for a single navigation rail demo. */ @@ -42,6 +48,39 @@ public View onCreateDemoView( layoutInflater.inflate( R.layout.cat_navigation_rail_submenus_fragment, viewGroup, /* attachToRoot= */ false); navigationRailView = view.findViewById(R.id.cat_navigation_rail); + // Add extended floating action button + navigationRailView.addHeaderView(R.layout.cat_navigation_rail_efab_header_view); + FrameLayout.LayoutParams lp = + (LayoutParams) navigationRailView.getHeaderView().getLayoutParams(); + lp.gravity = Gravity.START; + navigationRailView.getHeaderView(). + findViewById(R.id.cat_navigation_rail_efab_container) + .setPadding( + navigationRailView.getItemActiveIndicatorExpandedMarginHorizontal(), + 0, + navigationRailView.getItemActiveIndicatorExpandedMarginHorizontal(), + 0); + + ExtendedFloatingActionButton efab = + navigationRailView.getHeaderView().findViewById(R.id.cat_navigation_rail_efab); + efab.setAnimationEnabled(false); + efab.setExtended(false); + efab.setOnClickListener(v -> + Snackbar.make(v, R.string.cat_navigation_rail_efab_message, Snackbar.LENGTH_SHORT) + .show()); + + ImageView button = + navigationRailView.getHeaderView().findViewById(R.id.cat_navigation_rail_expand_button); + button.setOnClickListener( + v -> { + if (efab.isExtended()) { + efab.shrink(); + navigationRailView.collapse(); + } else { + efab.extend(); + navigationRailView.expand(); + } + }); return view; } } diff --git a/catalog/java/io/material/catalog/navigationrail/res/layout/cat_navigation_rail_efab_header_view.xml b/catalog/java/io/material/catalog/navigationrail/res/layout/cat_navigation_rail_efab_header_view.xml new file mode 100644 index 00000000000..d6fe3022689 --- /dev/null +++ b/catalog/java/io/material/catalog/navigationrail/res/layout/cat_navigation_rail_efab_header_view.xml @@ -0,0 +1,39 @@ + + + + + + diff --git a/catalog/java/io/material/catalog/navigationrail/res/layout/cat_navigation_rail_submenus_fragment.xml b/catalog/java/io/material/catalog/navigationrail/res/layout/cat_navigation_rail_submenus_fragment.xml index e26d3a42d0b..0d9a2cdaa2b 100644 --- a/catalog/java/io/material/catalog/navigationrail/res/layout/cat_navigation_rail_submenus_fragment.xml +++ b/catalog/java/io/material/catalog/navigationrail/res/layout/cat_navigation_rail_submenus_fragment.xml @@ -14,13 +14,29 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - + android:layout_width="match_parent" + android:layout_height="match_parent"> + + + + + diff --git a/catalog/java/io/material/catalog/navigationrail/res/menu/navigation_rail_submenus_menu.xml b/catalog/java/io/material/catalog/navigationrail/res/menu/navigation_rail_submenus_menu.xml index 6cd1bb5b5eb..9a132db9bb5 100644 --- a/catalog/java/io/material/catalog/navigationrail/res/menu/navigation_rail_submenus_menu.xml +++ b/catalog/java/io/material/catalog/navigationrail/res/menu/navigation_rail_submenus_menu.xml @@ -74,4 +74,26 @@ android:title="@string/cat_navigation_rail_page_10_name"/> + + + + + + + + + + + + diff --git a/catalog/java/io/material/catalog/navigationrail/res/values/strings.xml b/catalog/java/io/material/catalog/navigationrail/res/values/strings.xml index fa5409cd373..f851016de97 100644 --- a/catalog/java/io/material/catalog/navigationrail/res/values/strings.xml +++ b/catalog/java/io/material/catalog/navigationrail/res/values/strings.xml @@ -54,8 +54,17 @@ description="The title of a page in the navigation rail demo. [CHAR_LIMIT=50]">Page 9 Page 10 + + Page 11 + Page 12 Subheader 1 + Subheader 2 + Subheader 3 Add destination @@ -116,4 +125,10 @@ description="Content description for a floating action button in the navigation rail.[CHAR_LIMIT=50]">Navigation Rail FAB The orientation of this Navigation Rail demo has been locked to portrait mode, because landscape mode results in a compact height on this device. For any compact screen dimensions, use a Bottom Navigation Bar instead. + + Label + center + Button clicked + Button to expand or collapse the navigation rail + diff --git a/lib/java/com/google/android/material/floatingactionbutton/ExtendedFloatingActionButton.java b/lib/java/com/google/android/material/floatingactionbutton/ExtendedFloatingActionButton.java index 51af51389e3..ae6beabab2b 100644 --- a/lib/java/com/google/android/material/floatingactionbutton/ExtendedFloatingActionButton.java +++ b/lib/java/com/google/android/material/floatingactionbutton/ExtendedFloatingActionButton.java @@ -920,10 +920,7 @@ private boolean isOrWillBeHidden() { /** * Set whether or not animations are enabled. - * - * @hide */ - @RestrictTo(LIBRARY_GROUP) public void setAnimationEnabled(boolean animationEnabled) { this.animationEnabled = animationEnabled; } diff --git a/lib/java/com/google/android/material/navigation/NavigationBarItemView.java b/lib/java/com/google/android/material/navigation/NavigationBarItemView.java index 41bd9ccc7b7..b76a8e9c5e2 100644 --- a/lib/java/com/google/android/material/navigation/NavigationBarItemView.java +++ b/lib/java/com/google/android/material/navigation/NavigationBarItemView.java @@ -210,11 +210,11 @@ public NavigationBarItemView(@NonNull Context context) { } // If item icon gravity is start, we want to update the active indicator width in a layout // change listener to keep the active indicator size up to date with the content width. + LayoutParams lp = (LayoutParams) innerContentContainer.getLayoutParams(); + int newWidth = right - left + lp.rightMargin + lp.leftMargin; if (itemIconGravity == ITEM_ICON_GRAVITY_START && activeIndicatorExpandedDesiredWidth == ACTIVE_INDICATOR_WIDTH_WRAP_CONTENT - && (right - left) != (oldRight - oldLeft)) { - LayoutParams lp = (LayoutParams) innerContentContainer.getLayoutParams(); - int newWidth = right - left + lp.rightMargin + lp.leftMargin; + && newWidth != activeIndicatorView.getMeasuredWidth()) { LayoutParams indicatorParams = (LayoutParams) activeIndicatorView.getLayoutParams(); int minWidth = min( @@ -308,6 +308,16 @@ public int getItemPosition() { return itemPosition; } + @NonNull + public BaselineLayout getLabelGroup() { + return labelGroup; + } + + @NonNull + public BaselineLayout getExpandedLabelGroup() { + return expandedLabelGroup; + } + public void setShifting(boolean shifting) { if (isShifting != shifting) { isShifting = shifting; @@ -1103,7 +1113,11 @@ public void setActiveIndicatorExpandedHeight(int height) { private void updateActiveIndicatorLayoutParams(int availableWidth) { // Set width to the min of either the desired indicator width or the available width minus // a horizontal margin. - if (availableWidth <= 0) { + if (availableWidth <= 0 && getVisibility() == VISIBLE) { + // Return early if there's not yet an available width and the view is visible; this will be + // called again when there is an available width. Otherwise if the available width is 0 due to + // the view being gone, we still want to set layout params so that when the view appears, + // there is no jump in animation from turning visible and then adjusting the height/width. return; } @@ -1125,7 +1139,7 @@ private void updateActiveIndicatorLayoutParams(int availableWidth) { // If the label visibility is unlabeled, make the active indicator's height equal to its // width. indicatorParams.height = isActiveIndicatorResizeableAndUnlabeled() ? newWidth : newHeight; - indicatorParams.width = newWidth; + indicatorParams.width = max(0, newWidth); activeIndicatorView.setLayoutParams(indicatorParams); } diff --git a/lib/java/com/google/android/material/navigationrail/LabelMoveTransition.java b/lib/java/com/google/android/material/navigationrail/LabelMoveTransition.java new file mode 100644 index 00000000000..e59ef62ebbd --- /dev/null +++ b/lib/java/com/google/android/material/navigationrail/LabelMoveTransition.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2024 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.navigationrail; + +import android.animation.Animator; +import android.animation.ValueAnimator; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.transition.Transition; +import androidx.transition.TransitionValues; +import com.google.android.material.navigation.NavigationBarMenuItemView; + +/** + * A {@link Transition} that animates the {@link NavigationBarMenuItemView} label horizontally when + * the label is fading in. + */ +class LabelMoveTransition extends Transition { + + private static final String LABEL_VISIBILITY = "NavigationRailLabelVisibility"; + private static final float HORIZONTAL_DISTANCE = -30f; + + @Override + public void captureStartValues(@NonNull TransitionValues transitionValues) { + transitionValues.values.put(LABEL_VISIBILITY, transitionValues.view.getVisibility()); + } + + @Override + public void captureEndValues(@NonNull TransitionValues transitionValues) { + transitionValues.values.put(LABEL_VISIBILITY, transitionValues.view.getVisibility()); + } + + @Nullable + @Override + public Animator createAnimator(@NonNull ViewGroup sceneRoot, + @Nullable TransitionValues startValues, + @Nullable TransitionValues endValues) { + if (startValues == null || endValues == null + || startValues.values.get(LABEL_VISIBILITY) == null + || endValues.values.get(LABEL_VISIBILITY) == null) { + return super.createAnimator(sceneRoot, startValues, endValues); + } + // Only animate if the view is appearing + if ((int) startValues.values.get(LABEL_VISIBILITY) != View.GONE + || (int) endValues.values.get(LABEL_VISIBILITY) != View.VISIBLE) { + return super.createAnimator(sceneRoot, startValues, endValues); + } + View view = endValues.view; + ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f); + animator.addUpdateListener( + animation -> { + float progress = animation.getAnimatedFraction(); + view.setTranslationX(HORIZONTAL_DISTANCE * (1 - progress)); + }); + return animator; + } +} diff --git a/lib/java/com/google/android/material/navigationrail/NavigationRailView.java b/lib/java/com/google/android/material/navigationrail/NavigationRailView.java index 4c562194ba7..bb4e32257e5 100644 --- a/lib/java/com/google/android/material/navigationrail/NavigationRailView.java +++ b/lib/java/com/google/android/material/navigationrail/NavigationRailView.java @@ -25,12 +25,15 @@ import static java.lang.Math.max; import static java.lang.Math.min; +import android.animation.TimeInterpolator; import android.content.Context; import androidx.appcompat.widget.TintTypedArray; import android.util.AttributeSet; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; +import android.view.ViewGroup; +import android.view.animation.PathInterpolator; import android.widget.FrameLayout; import android.widget.ScrollView; import androidx.annotation.LayoutRes; @@ -40,10 +43,16 @@ import androidx.annotation.RestrictTo; import androidx.core.graphics.Insets; import androidx.core.view.WindowInsetsCompat; +import androidx.transition.ChangeBounds; +import androidx.transition.Fade; +import androidx.transition.Transition; +import androidx.transition.TransitionManager; +import androidx.transition.TransitionSet; import com.google.android.material.animation.AnimationUtils; import com.google.android.material.internal.ThemeEnforcement; import com.google.android.material.internal.ViewUtils; import com.google.android.material.internal.ViewUtils.RelativePadding; +import com.google.android.material.navigation.NavigationBarItemView; import com.google.android.material.navigation.NavigationBarView; import com.google.android.material.resources.MaterialResources; @@ -111,6 +120,14 @@ public class NavigationRailView extends NavigationBarView { private static final int DEFAULT_HEADER_GRAVITY = Gravity.TOP | Gravity.CENTER_HORIZONTAL; static final int NO_ITEM_MINIMUM_HEIGHT = -1; + // These are the values for the cubic bezier curve to mimic the spring curve with a damping + // ratio of 0.8 and stiffness value of 380. + private static final int EXPAND_DURATION = 500; + private static final TimeInterpolator CUBIC_BEZIER_INTERPOLATOR = + new PathInterpolator(0.38f, 1.21f, 0.22f, 1.00f); + + private static final int FADE_DURATION = 100; + private final int contentMarginTop; private final int headerMarginBottom; private final int minExpandedWidth; @@ -240,6 +257,74 @@ public NavigationRailView( applyWindowInsets(); } + private void startTransitionAnimation() { + if (!isLaidOut()) { + return; + } + Transition changeBoundsTransition = new ChangeBounds().setDuration(EXPAND_DURATION) + .setInterpolator(CUBIC_BEZIER_INTERPOLATOR); + Transition labelFadeInTransition = new Fade().setDuration(FADE_DURATION); + Transition labelFadeOutTransition = new Fade().setDuration(FADE_DURATION); + Transition labelHorizontalMoveTransition = new LabelMoveTransition(); + Transition fadingItemsTransition = new Fade().setDuration(FADE_DURATION); + // Remove all label groups from being targeted by ChangeBounds as we want a different transition + // for it + int childCount = getNavigationRailMenuView().getChildCount(); + for (int i = 0; i < childCount; i++) { + View item = getNavigationRailMenuView().getChildAt(i); + if (item instanceof NavigationBarItemView) { + // Exclude labels from ChangeBounds transition + changeBoundsTransition.excludeTarget(((NavigationBarItemView) item).getLabelGroup(), true); + changeBoundsTransition.excludeTarget( + ((NavigationBarItemView) item).getExpandedLabelGroup(), true); + + // If currently expanded, we are fading out the expanded label group and fading in the + // collapsed label group + if (expanded) { + labelFadeOutTransition.addTarget(((NavigationBarItemView) item).getExpandedLabelGroup()); + labelFadeInTransition.addTarget(((NavigationBarItemView) item).getLabelGroup()); + } else { + // Otherwise if we are collapsed, we fade out the collapsed label group and fade in the + // expanded + labelFadeOutTransition.addTarget(((NavigationBarItemView) item).getLabelGroup()); + labelFadeInTransition.addTarget(((NavigationBarItemView) item).getExpandedLabelGroup()); + } + labelHorizontalMoveTransition.addTarget( + ((NavigationBarItemView) item).getExpandedLabelGroup()); + } + fadingItemsTransition.addTarget(item); + } + + TransitionSet changeBoundsFadeLabelInTransition = new TransitionSet(); + changeBoundsFadeLabelInTransition.setOrdering(TransitionSet.ORDERING_TOGETHER); + changeBoundsFadeLabelInTransition + .addTransition(changeBoundsTransition) + .addTransition(labelFadeInTransition) + .addTransition(labelHorizontalMoveTransition); + + // If collapsed, we want to fade in the hidden nav items with the labels + if (!expanded) { + changeBoundsFadeLabelInTransition.addTransition(fadingItemsTransition); + } + + TransitionSet fadeOutTransitions = new TransitionSet(); + fadeOutTransitions.setOrdering(TransitionSet.ORDERING_TOGETHER); + fadeOutTransitions.addTransition(labelFadeOutTransition); + + // If expanded, we want to fade out the nav items to hide with the labels + if (expanded) { + fadeOutTransitions.addTransition(fadingItemsTransition); + } + + TransitionSet transitionSet = new TransitionSet(); + transitionSet.setOrdering(TransitionSet.ORDERING_SEQUENTIAL); + transitionSet + .addTransition(fadeOutTransitions) + .addTransition(changeBoundsFadeLabelInTransition); + + TransitionManager.beginDelayedTransition((ViewGroup) getParent(), transitionSet); + } + @Override public void setItemIconGravity(int itemIconGravity) { collapsedIconGravity = itemIconGravity; @@ -268,6 +353,7 @@ private void setExpanded(boolean expanded) { if (this.expanded == expanded) { return; } + startTransitionAnimation(); this.expanded = expanded; int iconGravity = collapsedIconGravity; int itemSpacing = collapsedItemSpacing; @@ -286,6 +372,21 @@ private void setExpanded(boolean expanded) { getNavigationRailMenuView().setExpanded(expanded); } + /** Expand the navigation rail. */ + public void expand() { + setExpanded(true); + } + + /** Returns whether or not the navigation rail is currently expanded. **/ + public boolean isExpanded() { + return expanded; + } + + /** Collapse the navigation rail. */ + public void collapse() { + setExpanded(false); + } + private void applyWindowInsets() { ViewUtils.doOnApplyWindowInsets( this, @@ -343,6 +444,9 @@ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (expanded) { // Try measuring child with no other restrictions than existing measure spec measureChild(getNavigationRailMenuView(), widthMeasureSpec, heightMeasureSpec); + if (headerView != null) { + measureChild(headerView, widthMeasureSpec, heightMeasureSpec); + } // Measure properly with the max child width minWidthSpec = makeExpandedWidthMeasureSpec(widthMeasureSpec, getMaxChildWidth()); } @@ -444,8 +548,12 @@ public void setItemMinimumHeight(@Px int minHeight) { menuView.setItemMinimumHeight(minHeight); } - // TODO: b/361189184 - Make public once expanded state is public - private void setCollapsedItemMinimumHeight(@Px int minHeight) { + /** + * Sets the minimum height of a navigation rail menu item when the navigation rail is collapsed. + * + * @param minHeight the min height of the item when the nav rail is collapsed + */ + public void setCollapsedItemMinimumHeight(@Px int minHeight) { collapsedItemMinHeight = minHeight; if (!expanded) { ((NavigationRailMenuView) getMenuView()).setItemMinimumHeight(minHeight); @@ -461,8 +569,13 @@ public void setItemSpacing(@Px int itemSpacing) { getNavigationRailMenuView().setItemSpacing(itemSpacing); } - // TODO: b/361189184 - Make public once expanded state is public - private void setCollapsedItemSpacing(@Px int itemSpacing) { + /** + * Sets the padding in between the navigation rail menu items when the navigation rail is + * collapsed. + * + * @param itemSpacing the desired item spacing in between the items when the nav rail is collapsed + */ + public void setCollapsedItemSpacing(@Px int itemSpacing) { this.collapsedItemSpacing = itemSpacing; if (!expanded) { getNavigationRailMenuView().setItemSpacing(itemSpacing); @@ -514,6 +627,10 @@ private int makeExpandedWidthMeasureSpec(int measureSpec, int measuredWidth) { if (MeasureSpec.getMode(measureSpec) != MeasureSpec.EXACTLY) { int newWidth = max(measuredWidth, minWidth); + // Also take into account header view max + if (headerView != null) { + newWidth = max(newWidth, headerView.getMeasuredWidth()); + } newWidth = max(getSuggestedMinimumWidth(), min(newWidth, maxExpandedWidth)); return MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.EXACTLY); } @@ -531,6 +648,7 @@ private void addContentContainer() { contentContainer = new NavigationRailFrameLayout(getContext()); contentContainer.setPaddingTop(contentMarginTop); contentContainer.setScrollingEnabled(scrollingEnabled); + contentContainer.setClipChildren(false); contentContainer.setLayoutParams(new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)); menuView.setLayoutParams(new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)); contentContainer.addView(menuView);