/* * Copyright (c) 2019. André Mion * * 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.andremion.floatingnavigationview; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.PixelFormat; import android.graphics.Rect; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.os.Parcel; import android.os.Parcelable; import android.util.AttributeSet; import android.util.SparseArray; import android.view.Gravity; import android.view.LayoutInflater; import android.view.Menu; import android.view.MotionEvent; import android.view.View; import android.view.ViewTreeObserver; import android.view.WindowManager; import android.widget.FrameLayout; import android.widget.ImageView; import com.google.android.material.circularreveal.CircularRevealCompat; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.internal.NavigationMenuView; import com.google.android.material.navigation.NavigationView; import androidx.annotation.ColorInt; import androidx.annotation.IdRes; import androidx.annotation.LayoutRes; import androidx.annotation.MenuRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatDelegate; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.os.ParcelableCompat; import androidx.core.os.ParcelableCompatCreatorCallbacks; import androidx.core.view.MotionEventCompat; import androidx.core.view.ViewCompat; import androidx.customview.view.AbsSavedState; import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat; @SuppressWarnings({"FieldCanBeLocal", "InflateParams", "RtlHardcoded", "unused", "WeakerAccess"}) public class FloatingNavigationView extends FloatingActionButton { private final WindowManager mWindowManager; private final CircularRevealNavigationView mNavigationView; private final NavigationMenuView mNavigationMenuView; private final ImageView mFabView; private final Rect mFabRect = new Rect(); private final Rect mNavigationRect = new Rect(); private final boolean mDrawMenuBelowFab; private final OnTouchListener mNavigationTouchListener = new OnTouchListener() { @Override public boolean onTouch(@NonNull View v, @NonNull MotionEvent event) { final int x = (int) event.getX(); final int y = (int) event.getY(); if ((MotionEventCompat.getActionMasked(event) == MotionEvent.ACTION_DOWN) && ((x < 0) || (x >= mNavigationView.getWidth()) || (y < 0) || (y >= mNavigationView.getHeight()))) { close(); return true; } else if (MotionEventCompat.getActionMasked(event) == MotionEvent.ACTION_OUTSIDE) { close(); return true; } return false; } }; private final OnClickListener mFabClickListener = new OnClickListener() { @Override public void onClick(View v) { close(); } }; public FloatingNavigationView(Context context) { this(context, null); } public FloatingNavigationView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public FloatingNavigationView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); AppCompatDelegate.setCompatVectorFromResourcesEnabled(true); setImageResource(R.drawable.ic_menu_vector); mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); mNavigationView = (CircularRevealNavigationView) LayoutInflater.from(context).inflate(R.layout.navigation_view, null); mNavigationView.setBackground(new ColorDrawable(getBackgroundColor())); mNavigationView.setOnTouchListener(mNavigationTouchListener); mNavigationMenuView = (NavigationMenuView) mNavigationView.findViewById(R.id.design_navigation_view); mFabView = (ImageView) mNavigationView.findViewById(R.id.fab_view); mFabView.setOnClickListener(mFabClickListener); mFabView.setContentDescription(getContentDescription()); mFabView.bringToFront(); // Custom attributes TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MenuView, defStyleAttr, R.style.Widget_Design_NavigationView); if (a.hasValue(R.styleable.MenuView_menu)) { mNavigationView.inflateMenu(a.getResourceId(R.styleable.MenuView_menu, 0)); } if (a.hasValue(R.styleable.MenuView_headerLayout)) { mNavigationView.inflateHeaderView(a.getResourceId(R.styleable.MenuView_headerLayout, 0)); } mDrawMenuBelowFab = a.getBoolean(R.styleable.MenuView_drawMenuBelowFab, false); a.recycle(); } private @ColorInt int getBackgroundColor() { ColorStateList colorStateList = getBackgroundTintList(); if (colorStateList != null) { return colorStateList.getDefaultColor(); } Drawable background = getBackground(); if (background instanceof ColorDrawable) { return ((ColorDrawable) background).getColor(); } return getSolidColor(); } @Override public void setBackgroundTintList(@Nullable ColorStateList tint) { super.setBackgroundTintList(tint); ViewCompat.setBackgroundTintList(mNavigationView, tint); } @NonNull private static WindowManager.LayoutParams createLayoutParams(int gravity) { WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.TYPE_APPLICATION_PANEL, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, PixelFormat.TRANSLUCENT); layoutParams.gravity = gravity; return layoutParams; } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); detachNavigationView(); // Prevent Leaked Window when configuration changes } public void open() { if (isOpened()) { return; } attachNavigationView(); mNavigationMenuView.scrollToPosition(0); mNavigationView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { mNavigationView.getViewTreeObserver().removeOnGlobalLayoutListener(this); updateFabBounds(); drawMenuBelowFab(); startOpenAnimations(); } }); } public void close() { if (!isOpened()) { return; } startCloseAnimations(); } public boolean isOpened() { return mNavigationView.getParent() != null; } private void attachNavigationView() { CoordinatorLayout.LayoutParams layoutParams = (CoordinatorLayout.LayoutParams) getLayoutParams(); int gravity = Gravity.LEFT; if (layoutParams.getAnchorId() != View.NO_ID && layoutParams.anchorGravity != Gravity.NO_GRAVITY) { if (Gravity.isHorizontal(layoutParams.anchorGravity)) { gravity = layoutParams.anchorGravity; } } else if (layoutParams.gravity != Gravity.NO_GRAVITY) { if (Gravity.isHorizontal(layoutParams.gravity)) { gravity = layoutParams.gravity; } } // Gravity.START and Gravity.END don't work for views added in WindowManager with RTL. // We need to convert script specific gravity to absolute horizontal value // If horizontal direction is LTR, then START will set LEFT and END will set RIGHT. // If horizontal direction is RTL, then START will set RIGHT and END will set LEFT. gravity = Gravity.getAbsoluteGravity(gravity, ViewCompat.getLayoutDirection(this)); mWindowManager.addView(mNavigationView, createLayoutParams(gravity)); } private void detachNavigationView() { if (isOpened()) { mWindowManager.removeViewImmediate(mNavigationView); } } private void startOpenAnimations() { // Icon AnimatedVectorDrawableCompat menuIcon = AnimatedVectorDrawableCompat.create(getContext(), R.drawable.ic_menu_animated); mFabView.setImageDrawable(menuIcon); menuIcon.start(); // Reveal int centerX = mFabRect.centerX(); int centerY = mFabRect.centerY(); float startRadius = getMinRadius(); float endRadius = getMaxRadius(); Animator reveal = CircularRevealCompat.createCircularReveal( mNavigationView, centerX, centerY, startRadius, endRadius ); // Fade in mNavigationMenuView.setAlpha(0); Animator fade = ObjectAnimator.ofFloat(mNavigationMenuView, View.ALPHA, 0, 1); // Animations AnimatorSet set = new AnimatorSet(); set.playSequentially(reveal, fade); set.start(); } private void startCloseAnimations() { // Icon AnimatedVectorDrawableCompat closeIcon = AnimatedVectorDrawableCompat.create(getContext(), R.drawable.ic_close_animated); mFabView.setImageDrawable(closeIcon); closeIcon.start(); // Unreveal int centerX = mFabRect.centerX(); int centerY = mFabRect.centerY(); float startRadius = getMaxRadius(); float endRadius = getMinRadius(); Animator reveal = CircularRevealCompat.createCircularReveal( mNavigationView, centerX, centerY, startRadius, endRadius ); reveal.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { detachNavigationView(); } }); // Fade out Animator fade = ObjectAnimator.ofFloat(mNavigationMenuView, View.ALPHA, 1, 0); // Animations AnimatorSet set = new AnimatorSet(); set.playTogether(fade, reveal); set.start(); } private void updateFabBounds() { updateFabRect(); updateNavigationRect(); FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) mFabView.getLayoutParams(); layoutParams.width = getWidth(); layoutParams.height = getHeight(); layoutParams.topMargin = mFabRect.top; if (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL) { layoutParams.rightMargin = mNavigationView.getWidth() - mFabRect.right; } else { layoutParams.leftMargin = mFabRect.left; } mFabView.setLayoutParams(layoutParams); } private void updateFabRect() { getGlobalVisibleRect(mFabRect); int[] offset = new int[2]; mNavigationView.getLocationOnScreen(offset); mFabRect.offset(-offset[0], -offset[1]); } private void updateNavigationRect() { mNavigationView.getGlobalVisibleRect(mNavigationRect); } private void drawMenuBelowFab() { if (mDrawMenuBelowFab) { // Add padding top to positioning menu below the FAB mNavigationMenuView.setPadding( mNavigationMenuView.getPaddingLeft(), mFabRect.bottom, mNavigationMenuView.getPaddingRight(), mNavigationMenuView.getPaddingBottom()); } } private float getMinRadius() { return mFabRect.width() / 2f; } private float getMaxRadius() { return (float) Math.hypot(mNavigationRect.width(), mNavigationRect.height()); } /** * Set a listener that will be notified when a menu item is clicked. * * @param listener The listener to notify */ public void setNavigationItemSelectedListener(@Nullable NavigationView.OnNavigationItemSelectedListener listener) { mNavigationView.setNavigationItemSelectedListener(listener); } /* {@link NavigationView} methods */ /** * Inflate a menu resource into this navigation view. * <p/> * <p>Existing items in the menu will not be modified or removed.</p> * * @param resId ID of a menu resource to inflate */ public void inflateMenu(@MenuRes int resId) { mNavigationView.inflateMenu(resId); } /** * Returns the {@link Menu} instance associated with this navigation view. */ public Menu getMenu() { return mNavigationView.getMenu(); } /** * Inflates a View and add it as a header of the navigation menu. * * @param res The layout resource ID. * @return a newly inflated View. */ public View inflateHeaderView(@LayoutRes int res) { return mNavigationView.inflateHeaderView(res); } /** * Adds a View as a header of the navigation menu. * * @param view The view to be added as a header of the navigation menu. */ public void addHeaderView(@NonNull View view) { mNavigationView.addHeaderView(view); } /** * Removes a previously-added header view. * * @param view The view to remove */ public void removeHeaderView(@NonNull View view) { mNavigationView.removeHeaderView(view); } /** * Gets the number of headers in this NavigationView. * * @return A positive integer representing the number of headers. */ public int getHeaderCount() { return mNavigationView.getHeaderCount(); } /** * Gets the header view at the specified position. * * @param index The position at which to get the view from. * @return The header view the specified position or null if the position does not exist in this * NavigationView. */ public View getHeaderView(int index) { return mNavigationView.getHeaderView(index); } /** * Sets the currently checked item in this navigation menu. * * @param id The item ID of the currently checked item. */ public void setCheckedItem(@IdRes int id) { mNavigationView.setCheckedItem(id); } /** * {@link SavedState} methods */ @Override protected Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); SavedState ss = new SavedState(superState); ss.navigationViewState = new SparseArray(); //noinspection unchecked mNavigationView.saveHierarchyState(ss.navigationViewState); ss.opened = isOpened(); return ss; } @Override protected void onRestoreInstanceState(Parcelable state) { SavedState ss = (SavedState) state; super.onRestoreInstanceState(ss.getSuperState()); //noinspection unchecked mNavigationView.restoreHierarchyState(ss.navigationViewState); if (ss.opened) { mFabView.setImageResource(R.drawable.ic_close_vector); // Run on post to prevent "unable to add window -- token null is not valid" error post(new Runnable() { @Override public void run() { attachNavigationView(); mNavigationView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { mNavigationView.getViewTreeObserver().removeOnGlobalLayoutListener(this); updateFabBounds(); drawMenuBelowFab(); } }); } }); } } public static class SavedState extends AbsSavedState { private SparseArray navigationViewState; private boolean opened; private SavedState(Parcel in, ClassLoader loader) { super(in, loader); navigationViewState = in.readSparseArray(loader); opened = (Boolean) in.readValue(loader); } private SavedState(Parcelable superState) { super(superState); } @Override public void writeToParcel(@NonNull Parcel dest, int flags) { super.writeToParcel(dest, flags); //noinspection unchecked dest.writeSparseArray(navigationViewState); dest.writeValue(opened); } @Override public String toString() { return FloatingNavigationView.class.getSimpleName() + "." + SavedState.class.getSimpleName() + "{" + Integer.toHexString(System.identityHashCode(this)) + " opened=" + opened + "}"; } public static final Parcelable.Creator<SavedState> CREATOR = ParcelableCompat.newCreator(new ParcelableCompatCreatorCallbacks<SavedState>() { @Override public SavedState createFromParcel(Parcel parcel, ClassLoader loader) { return new SavedState(parcel, loader); } @Override public SavedState[] newArray(int size) { return new SavedState[size]; } }); } }