1/*
2 * Copyright (C) 2017 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package androidx.car.drawer;
18
19import android.content.Context;
20import android.content.res.Configuration;
21import android.os.Bundle;
22import android.view.Gravity;
23import android.view.MenuItem;
24import android.view.View;
25import android.view.animation.AnimationUtils;
26import android.widget.ProgressBar;
27import android.widget.TextView;
28
29import androidx.annotation.AnimRes;
30import androidx.annotation.NonNull;
31import androidx.annotation.Nullable;
32import androidx.appcompat.app.ActionBarDrawerToggle;
33import androidx.appcompat.widget.Toolbar;
34import androidx.car.R;
35import androidx.car.widget.PagedListView;
36import androidx.drawerlayout.widget.DrawerLayout;
37import androidx.recyclerview.widget.RecyclerView;
38
39import java.util.ArrayDeque;
40
41/**
42 * A controller that will handle the set up of the navigation drawer. It will hook up the
43 * necessary buttons for up navigation, as well as expose methods to allow for a drill down
44 * navigation.
45 */
46public class CarDrawerController {
47    /** An animation for when a user navigates into a submenu. */
48    @AnimRes
49    private static final int DRILL_DOWN_ANIM = R.anim.fade_in_trans_right_layout_anim;
50
51    /** An animation for when a user navigates up (when the back button is pressed). */
52    @AnimRes
53    private static final int NAVIGATE_UP_ANIM = R.anim.fade_in_trans_left_layout_anim;
54
55    /**
56     * A representation of the hierarchy of navigation being displayed in the list. The ordering of
57     * this stack is the order that the user has visited each level. When the user navigates up,
58     * the adapters are popped from this list.
59     */
60    private final ArrayDeque<CarDrawerAdapter> mAdapterStack = new ArrayDeque<>();
61
62    private final Context mContext;
63
64    private final TextView mTitleView;
65    private final DrawerLayout mDrawerLayout;
66    private final ActionBarDrawerToggle mDrawerToggle;
67
68    private final PagedListView mDrawerList;
69    private final ProgressBar mProgressBar;
70
71    /**
72     * @deprecated Use {@link #CarDrawerController(DrawerLayout, ActionBarDrawerToggle)} instead.
73     *             The {@code Toolbar} is no longer needed and will be ignored.
74     */
75    @Deprecated
76    public CarDrawerController(@Nullable Toolbar toolbar, @NonNull DrawerLayout drawerLayout,
77            @NonNull ActionBarDrawerToggle drawerToggle) {
78        this(drawerLayout, drawerToggle);
79    }
80
81    /**
82     * Creates a {@link CarDrawerController} that will control the navigation of the drawer given by
83     * {@code drawerLayout}.
84     *
85     * <p>The given {@code drawerLayout} should either have a child View that is inflated from
86     * {@code R.layout.car_drawer} or ensure that it three children that have the IDs found in that
87     * layout.
88     *
89     * @param drawerLayout The top-level container for the window content that shows the
90     *                     interactive drawer.
91     * @param drawerToggle The {@link ActionBarDrawerToggle} that will open the drawer.
92     */
93    public CarDrawerController(@NonNull DrawerLayout drawerLayout,
94            @NonNull ActionBarDrawerToggle drawerToggle) {
95        mContext = drawerLayout.getContext();
96        mDrawerToggle = drawerToggle;
97        mDrawerLayout = drawerLayout;
98
99        mTitleView = drawerLayout.findViewById(R.id.drawer_title);
100        mDrawerList = drawerLayout.findViewById(R.id.drawer_list);
101        mDrawerList.setMaxPages(PagedListView.ItemCap.UNLIMITED);
102        mProgressBar = drawerLayout.findViewById(R.id.drawer_progress);
103
104        drawerLayout.findViewById(R.id.drawer_back_button).setOnClickListener(v -> {
105            if (!maybeHandleUpClick()) {
106                closeDrawer();
107            }
108        });
109
110        setupDrawerToggling();
111    }
112
113    /**
114     * Sets the {@link CarDrawerAdapter} that will function as the root adapter. The contents of
115     * this root adapter are shown when the drawer is first opened. It is also the top-most level of
116     * navigation in the drawer.
117     *
118     * @param rootAdapter The adapter that will act as the root. If this value is {@code null}, then
119     *                    this method will do nothing.
120     */
121    public void setRootAdapter(@Nullable CarDrawerAdapter rootAdapter) {
122        if (rootAdapter == null) {
123            return;
124        }
125
126        // The root adapter is always the last item in the stack.
127        if (!mAdapterStack.isEmpty()) {
128            mAdapterStack.removeLast();
129        }
130        mAdapterStack.addLast(rootAdapter);
131        setDisplayAdapter(rootAdapter);
132    }
133
134    /**
135     * Switches to use the given {@link CarDrawerAdapter} as the one to supply the list to display
136     * in the navigation drawer. The title will also be updated from the adapter.
137     *
138     * <p>This switch is treated as a navigation to the next level in the drawer. Navigation away
139     * from this level will pop the given adapter off and surface contents of the previous adapter
140     * that was set via this method. If no such adapter exists, then the root adapter set by
141     * {@link #setRootAdapter(CarDrawerAdapter)} will be used instead.
142     *
143     * @param adapter Adapter for next level of content in the drawer.
144     */
145    public final void pushAdapter(CarDrawerAdapter adapter) {
146        mAdapterStack.peek().setTitleChangeListener(null);
147        mAdapterStack.push(adapter);
148        setDisplayAdapter(adapter);
149        runLayoutAnimation(DRILL_DOWN_ANIM);
150    }
151
152    /** Close the drawer. */
153    public void closeDrawer() {
154        if (mDrawerLayout.isDrawerOpen(Gravity.LEFT)) {
155            mDrawerLayout.closeDrawer(Gravity.LEFT);
156        }
157    }
158
159    /** Opens the drawer. */
160    public void openDrawer() {
161        if (!mDrawerLayout.isDrawerOpen(Gravity.LEFT)) {
162            mDrawerLayout.openDrawer(Gravity.LEFT);
163        }
164    }
165
166    /** Sets a listener to be notified of Drawer events. */
167    public void addDrawerListener(@NonNull DrawerLayout.DrawerListener listener) {
168        mDrawerLayout.addDrawerListener(listener);
169    }
170
171    /** Removes a listener to be notified of Drawer events. */
172    public void removeDrawerListener(@NonNull DrawerLayout.DrawerListener listener) {
173        mDrawerLayout.removeDrawerListener(listener);
174    }
175
176    /**
177     * Sets whether the loading progress bar is displayed in the navigation drawer. If {@code true},
178     * the progress bar is displayed and the navigation list is hidden and vice versa.
179     */
180    public void showLoadingProgressBar(boolean show) {
181        mDrawerList.setVisibility(show ? View.INVISIBLE : View.VISIBLE);
182        mProgressBar.setVisibility(show ? View.VISIBLE : View.GONE);
183    }
184
185    /** Scroll to given position in the list. */
186    public void scrollToPosition(int position) {
187        mDrawerList.getRecyclerView().smoothScrollToPosition(position);
188    }
189
190    /**
191     * Retrieves the title from the given {@link CarDrawerAdapter} and set its as the title of this
192     * controller's internal Toolbar.
193     */
194    private void setToolbarTitleFrom(CarDrawerAdapter adapter) {
195        mTitleView.setText(adapter.getTitle());
196        adapter.setTitleChangeListener(mTitleView::setText);
197    }
198
199    /**
200     * Sets up the necessary listeners for {@link DrawerLayout} so that the navigation drawer
201     * hierarchy is properly displayed.
202     */
203    private void setupDrawerToggling() {
204        mDrawerLayout.addDrawerListener(mDrawerToggle);
205        mDrawerLayout.addDrawerListener(
206                new DrawerLayout.DrawerListener() {
207                    @Override
208                    public void onDrawerSlide(View drawerView, float slideOffset) {}
209
210                    @Override
211                    public void onDrawerClosed(View drawerView) {
212                        // If drawer is closed, revert stack/drawer to initial root state.
213                        cleanupStackAndShowRoot();
214                        scrollToPosition(0);
215                    }
216
217                    @Override
218                    public void onDrawerOpened(View drawerView) {}
219
220                    @Override
221                    public void onDrawerStateChanged(int newState) {}
222                });
223    }
224
225    /**
226     * Synchronizes the display of the drawer with its linked {@link DrawerLayout}.
227     *
228     * <p>This should be called from the associated Activity's
229     * {@link androidx.appcompat.app.AppCompatActivity#onPostCreate(Bundle)} method to synchronize
230     * after teh DRawerLayout's instance state has been restored, and any other time when the
231     * state may have diverged in such a way that this controller's associated
232     * {@link ActionBarDrawerToggle} had not been notified.
233     */
234    public void syncState() {
235        mDrawerToggle.syncState();
236    }
237
238    /**
239     * Notify this controller that device configurations may have changed.
240     *
241     * <p>This method should be called from the associated Activity's
242     * {@code onConfigurationChanged()} method.
243     */
244    public void onConfigurationChanged(Configuration newConfig) {
245        // Pass any configuration change to the drawer toggle.
246        mDrawerToggle.onConfigurationChanged(newConfig);
247    }
248
249    /**
250     * Sets the given adapter as the one displaying the current contents of the drawer.
251     *
252     * <p>The drawer's title will also be derived from the given adapter.
253     */
254    private void setDisplayAdapter(CarDrawerAdapter adapter) {
255        setToolbarTitleFrom(adapter);
256        // NOTE: We don't use swapAdapter() since different levels in the Drawer may switch between
257        // car_drawer_list_item_normal, car_drawer_list_item_small and car_list_empty layouts.
258        mDrawerList.getRecyclerView().setAdapter(adapter);
259    }
260
261    /**
262     * An analog to an Activity's {@code onOptionsItemSelected()}. This method should be called
263     * when the Activity's method is called and will return {@code true} if the selection has
264     * been handled.
265     *
266     * @return {@code true} if the item processing was handled by this class.
267     */
268    public boolean onOptionsItemSelected(MenuItem item) {
269        // Handle home-click and see if we can navigate up in the drawer.
270        if (item != null && item.getItemId() == android.R.id.home && maybeHandleUpClick()) {
271            return true;
272        }
273
274        // DrawerToggle gets next chance to handle up-clicks (and any other clicks).
275        return mDrawerToggle.onOptionsItemSelected(item);
276    }
277
278    /**
279     * Switches to the previous level in the drawer hierarchy if the current list being displayed
280     * is not the root adapter. This is analogous to a navigate up.
281     *
282     * @return {@code true} if a navigate up was possible and executed. {@code false} otherwise.
283     */
284    private boolean maybeHandleUpClick() {
285        // Check if already at the root level.
286        if (mAdapterStack.size() <= 1) {
287            return false;
288        }
289
290        CarDrawerAdapter adapter = mAdapterStack.pop();
291        adapter.setTitleChangeListener(null);
292        adapter.cleanup();
293        setDisplayAdapter(mAdapterStack.peek());
294        runLayoutAnimation(NAVIGATE_UP_ANIM);
295        return true;
296    }
297
298    /** Clears stack down to root adapter and switches to root adapter. */
299    private void cleanupStackAndShowRoot() {
300        while (mAdapterStack.size() > 1) {
301            CarDrawerAdapter adapter = mAdapterStack.pop();
302            adapter.setTitleChangeListener(null);
303            adapter.cleanup();
304        }
305        setDisplayAdapter(mAdapterStack.peek());
306        runLayoutAnimation(NAVIGATE_UP_ANIM);
307    }
308
309    /**
310     * Runs the given layout animation on the PagedListView. Running this animation will also
311     * refresh the contents of the list.
312     */
313    private void runLayoutAnimation(@AnimRes int animation) {
314        RecyclerView recyclerView = mDrawerList.getRecyclerView();
315        recyclerView.setLayoutAnimation(AnimationUtils.loadLayoutAnimation(mContext, animation));
316        recyclerView.getAdapter().notifyDataSetChanged();
317        recyclerView.scheduleLayoutAnimation();
318    }
319}
320