CarDrawerController.java

/*
 * Copyright (C) 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 androidx.car.drawer;

import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.os.Bundle;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.MenuItem;
import android.view.View;
import android.view.animation.AnimationUtils;
import android.widget.ProgressBar;
import android.widget.TextView;

import androidx.annotation.AnimRes;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBarDrawerToggle;
import androidx.car.R;
import androidx.car.util.DropShadowScrollListener;
import androidx.car.widget.PagedListView;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.recyclerview.widget.RecyclerView;

import java.util.ArrayDeque;

/**
 * A controller that will handle the set up of the navigation drawer. It will hook up the
 * necessary buttons for up navigation, as well as expose methods to allow for a drill down
 * navigation.
 */
public class CarDrawerController {
    /** An animation for when a user navigates into a submenu. */
    @AnimRes
    private static final int DRILL_DOWN_ANIM = R.anim.fade_in_trans_right_layout_anim;

    /** An animation for when a user navigates up (when the back button is pressed). */
    @AnimRes
    private static final int NAVIGATE_UP_ANIM = R.anim.fade_in_trans_left_layout_anim;

    /**
     * A representation of the hierarchy of navigation being displayed in the list. The ordering of
     * this stack is the order that the user has visited each level. When the user navigates up,
     * the adapters are popped from this list.
     */
    private final ArrayDeque<CarDrawerAdapter> mAdapterStack = new ArrayDeque<>();

    private final Context mContext;

    private final TextView mTitleView;
    private final DrawerLayout mDrawerLayout;
    private final ActionBarDrawerToggle mDrawerToggle;

    private final PagedListView mDrawerList;
    private final ProgressBar mProgressBar;

    /**
     * Creates a {@link CarDrawerController} that will control the navigation of the drawer given by
     * {@code drawerLayout}.
     *
     * <p>The given {@code drawerLayout} should either have a child view that is inflated from
     * {@code R.layout.car_drawer} or ensure that its child views have the IDs expected in that
     * layout. The ids expected can be configured in the theme by {@code R.attr.drawerBackButtonId},
     * {@code R.attr.drawerListId}, {@code R.attr.drawerTitleId} and
     * {@code R.attr.drawerProgressId}.
     *
     * @param drawerLayout The top-level container for the window content that shows the
     *                     interactive drawer.
     * @param drawerToggle The {@link ActionBarDrawerToggle} that will open the drawer.
     * {@link R.attr#drawerBackButtonId}
     * {@link R.attr#drawerListId}
     * {@link R.attr#drawerProgressId}
     * {@link R.attr#drawerTitleId}
     */
    public CarDrawerController(@NonNull DrawerLayout drawerLayout,
            @NonNull ActionBarDrawerToggle drawerToggle) {
        mContext = drawerLayout.getContext();
        mDrawerToggle = drawerToggle;
        mDrawerLayout = drawerLayout;

        TypedValue outValue = new TypedValue();
        Resources.Theme theme = mContext.getTheme();

        mTitleView = drawerLayout.findViewById(
                theme.resolveAttribute(R.attr.drawerTitleId, outValue, true)
                        ? outValue.resourceId
                        : R.id.car_drawer_title);
        mProgressBar = drawerLayout.findViewById(
                theme.resolveAttribute(R.attr.drawerProgressId, outValue, true)
                        ? outValue.resourceId
                        : R.id.car_drawer_progress);

        mDrawerList = drawerLayout.findViewById(
                theme.resolveAttribute(R.attr.drawerListId, outValue, true)
                        ? outValue.resourceId
                        : R.id.car_drawer_list);
        mDrawerList.setMaxPages(PagedListView.UNLIMITED_PAGES);

        View toolbar = drawerLayout.findViewById(
                theme.resolveAttribute(R.attr.drawerToolbarId, outValue, true)
                        ? outValue.resourceId
                        : R.id.drawer_toolbar);
        mDrawerList.setOnScrollListener(new DropShadowScrollListener(toolbar));

        @IdRes int backButtonId = theme.resolveAttribute(R.attr.drawerBackButtonId, outValue, true)
                ? outValue.resourceId
                : R.id.car_drawer_back_button;

        drawerLayout.findViewById(backButtonId).setOnClickListener(v -> {
            if (!maybeHandleUpClick()) {
                closeDrawer();
            }
        });

        setupDrawerToggling();
    }

    /**
     * Sets the {@link CarDrawerAdapter} that will function as the root adapter. The contents of
     * this root adapter are shown when the drawer is first opened. It is also the top-most level of
     * navigation in the drawer.
     *
     * @param rootAdapter The adapter that will act as the root. If this value is {@code null}, then
     *                    this method will do nothing.
     */
    public void setRootAdapter(@Nullable CarDrawerAdapter rootAdapter) {
        if (rootAdapter == null) {
            return;
        }

        // The root adapter is always the last item in the stack.
        if (!mAdapterStack.isEmpty()) {
            mAdapterStack.removeLast();
        }
        mAdapterStack.addLast(rootAdapter);
        setDisplayAdapter(rootAdapter);
    }

    /**
     * Switches to use the given {@link CarDrawerAdapter} as the one to supply the list to display
     * in the navigation drawer. The title will also be updated from the adapter.
     *
     * <p>This switch is treated as a navigation to the next level in the drawer. Navigation away
     * from this level will pop the given adapter off and surface contents of the previous adapter
     * that was set via this method. If no such adapter exists, then the root adapter set by
     * {@link #setRootAdapter(CarDrawerAdapter)} will be used instead.
     *
     * @param adapter Adapter for next level of content in the drawer.
     */
    public final void pushAdapter(CarDrawerAdapter adapter) {
        mAdapterStack.peek().setTitleChangeListener(null);
        mAdapterStack.push(adapter);
        setDisplayAdapter(adapter);
        runLayoutAnimation(DRILL_DOWN_ANIM);
    }

    /** Close the drawer. */
    public void closeDrawer() {
        if (mDrawerLayout.isDrawerOpen(Gravity.LEFT)) {
            mDrawerLayout.closeDrawer(Gravity.LEFT);
        }
    }

    /** Opens the drawer. */
    public void openDrawer() {
        if (!mDrawerLayout.isDrawerOpen(Gravity.LEFT)) {
            mDrawerLayout.openDrawer(Gravity.LEFT);
        }
    }

    /** Sets a listener to be notified of Drawer events. */
    public void addDrawerListener(@NonNull DrawerLayout.DrawerListener listener) {
        mDrawerLayout.addDrawerListener(listener);
    }

    /** Removes a listener to be notified of Drawer events. */
    public void removeDrawerListener(@NonNull DrawerLayout.DrawerListener listener) {
        mDrawerLayout.removeDrawerListener(listener);
    }

    /**
     * Sets whether the loading progress bar is displayed in the navigation drawer. If {@code true},
     * the progress bar is displayed and the navigation list is hidden and vice versa.
     */
    public void showLoadingProgressBar(boolean show) {
        mDrawerList.setVisibility(show ? View.INVISIBLE : View.VISIBLE);
        mProgressBar.setVisibility(show ? View.VISIBLE : View.GONE);
    }

    /** Scroll to given position in the list. */
    public void scrollToPosition(int position) {
        mDrawerList.getRecyclerView().smoothScrollToPosition(position);
    }

    /**
     * Retrieves the title from the given {@link CarDrawerAdapter} and set its as the title of this
     * controller's internal Toolbar.
     */
    private void setToolbarTitleFrom(CarDrawerAdapter adapter) {
        mTitleView.setText(adapter.getTitle());
        adapter.setTitleChangeListener(mTitleView::setText);
    }

    /**
     * Sets up the necessary listeners for {@link DrawerLayout} so that the navigation drawer
     * hierarchy is properly displayed.
     */
    private void setupDrawerToggling() {
        mDrawerLayout.addDrawerListener(mDrawerToggle);
        mDrawerLayout.addDrawerListener(
                new DrawerLayout.DrawerListener() {
                    @Override
                    public void onDrawerSlide(View drawerView, float slideOffset) {}

                    @Override
                    public void onDrawerClosed(View drawerView) {
                        // If drawer is closed, revert stack/drawer to initial root state.
                        cleanupStackAndShowRoot();
                        scrollToPosition(0);
                    }

                    @Override
                    public void onDrawerOpened(View drawerView) {}

                    @Override
                    public void onDrawerStateChanged(int newState) {}
                });
    }

    /**
     * Synchronizes the display of the drawer with its linked {@link DrawerLayout}.
     *
     * <p>This should be called from the associated Activity's
     * {@link androidx.appcompat.app.AppCompatActivity#onPostCreate(Bundle)} method to synchronize
     * after the DrawerLayout's instance state has been restored, and any other time when the
     * state may have diverged in such a way that this controller's associated
     * {@link ActionBarDrawerToggle} had not been notified.
     */
    public void syncState() {
        mDrawerToggle.syncState();
    }

    /**
     * Notify this controller that device configurations may have changed.
     *
     * <p>This method should be called from the associated Activity's
     * {@code onConfigurationChanged()} method.
     */
    public void onConfigurationChanged(Configuration newConfig) {
        // Pass any configuration change to the drawer toggle.
        mDrawerToggle.onConfigurationChanged(newConfig);
    }

    /**
     * Sets the given adapter as the one displaying the current contents of the drawer.
     *
     * <p>The drawer's title will also be derived from the given adapter.
     */
    private void setDisplayAdapter(CarDrawerAdapter adapter) {
        setToolbarTitleFrom(adapter);
        // NOTE: We don't use swapAdapter() since different levels in the Drawer may switch between
        // car_drawer_list_item_normal, car_drawer_list_item_small and car_list_empty layouts.
        mDrawerList.getRecyclerView().setAdapter(adapter);
    }

    /**
     * An analog to an Activity's {@code onOptionsItemSelected()}. This method should be called
     * when the Activity's method is called and will return {@code true} if the selection has
     * been handled.
     *
     * @return {@code true} if the item processing was handled by this class.
     */
    public boolean onOptionsItemSelected(MenuItem item) {
        // Handle home-click and see if we can navigate up in the drawer.
        if (item != null && item.getItemId() == android.R.id.home && maybeHandleUpClick()) {
            return true;
        }

        // DrawerToggle gets next chance to handle up-clicks (and any other clicks).
        return mDrawerToggle.onOptionsItemSelected(item);
    }

    /**
     * Switches to the previous level in the drawer hierarchy if the current list being displayed
     * is not the root adapter. This is analogous to a navigate up.
     *
     * @return {@code true} if a navigate up was possible and executed. {@code false} otherwise.
     */
    private boolean maybeHandleUpClick() {
        // Check if already at the root level.
        if (mAdapterStack.size() <= 1) {
            return false;
        }

        CarDrawerAdapter adapter = mAdapterStack.pop();
        adapter.setTitleChangeListener(null);
        adapter.cleanup();
        setDisplayAdapter(mAdapterStack.peek());
        runLayoutAnimation(NAVIGATE_UP_ANIM);
        return true;
    }

    /** Clears stack down to root adapter and switches to root adapter. */
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    void cleanupStackAndShowRoot() {
        while (mAdapterStack.size() > 1) {
            CarDrawerAdapter adapter = mAdapterStack.pop();
            adapter.setTitleChangeListener(null);
            adapter.cleanup();
        }
        setDisplayAdapter(mAdapterStack.peek());
        runLayoutAnimation(NAVIGATE_UP_ANIM);
    }

    /**
     * Runs the given layout animation on the PagedListView. Running this animation will also
     * refresh the contents of the list.
     */
    private void runLayoutAnimation(@AnimRes int animation) {
        RecyclerView recyclerView = mDrawerList.getRecyclerView();
        recyclerView.setLayoutAnimation(AnimationUtils.loadLayoutAnimation(mContext, animation));
        recyclerView.getAdapter().notifyDataSetChanged();
        recyclerView.scheduleLayoutAnimation();
    }
}