1 /*
2  * Copyright (C) 2016 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 
17 package com.android.documentsui;
18 
19 import android.content.Context;
20 import android.util.AttributeSet;
21 import android.view.GestureDetector;
22 import android.view.KeyEvent;
23 import android.view.LayoutInflater;
24 import android.view.MotionEvent;
25 import android.view.View;
26 import android.view.ViewGroup;
27 
28 import androidx.recyclerview.widget.LinearLayoutManager;
29 import androidx.recyclerview.widget.RecyclerView;
30 
31 import com.android.documentsui.NavigationViewManager.Breadcrumb;
32 import com.android.documentsui.NavigationViewManager.Environment;
33 import com.android.documentsui.dirlist.AccessibilityEventRouter;
34 
35 import java.util.function.Consumer;
36 import java.util.function.IntConsumer;
37 
38 /**
39  * Horizontal breadcrumb
40  */
41 public final class HorizontalBreadcrumb extends RecyclerView implements Breadcrumb {
42 
43     private static final int USER_NO_SCROLL_OFFSET_THRESHOLD = 5;
44 
45     private LinearLayoutManager mLayoutManager;
46     private BreadcrumbAdapter mAdapter;
47     private IntConsumer mClickListener;
48 
HorizontalBreadcrumb(Context context, AttributeSet attrs, int defStyleAttr)49     public HorizontalBreadcrumb(Context context, AttributeSet attrs, int defStyleAttr) {
50         super(context, attrs, defStyleAttr);
51     }
52 
HorizontalBreadcrumb(Context context, AttributeSet attrs)53     public HorizontalBreadcrumb(Context context, AttributeSet attrs) {
54         super(context, attrs);
55     }
56 
HorizontalBreadcrumb(Context context)57     public HorizontalBreadcrumb(Context context) {
58         super(context);
59     }
60 
61     @Override
setup(Environment env, com.android.documentsui.base.State state, IntConsumer listener)62     public void setup(Environment env,
63             com.android.documentsui.base.State state,
64             IntConsumer listener) {
65 
66         mClickListener = listener;
67         mLayoutManager = new HorizontalBreadcrumbLinearLayoutManager(
68                 getContext(), LinearLayoutManager.HORIZONTAL, false);
69         mAdapter = new BreadcrumbAdapter(state, env, this::onKey);
70         // Since we are using GestureDetector to detect click events, a11y services don't know which
71         // views are clickable because we aren't using View.OnClickListener. Thus, we need to use a
72         // custom accessibility delegate to route click events correctly.
73         // See AccessibilityClickEventRouter for more details on how we are routing these a11y
74         // events.
75         setAccessibilityDelegateCompat(
76                 new AccessibilityEventRouter(this,
77                         (View child) -> onAccessibilityClick(child), null));
78 
79         setLayoutManager(mLayoutManager);
80         addOnItemTouchListener(new ClickListener(getContext(), this::onSingleTapUp));
81     }
82 
83     @Override
show(boolean visibility)84     public void show(boolean visibility) {
85         if (visibility) {
86             setVisibility(VISIBLE);
87             boolean shouldScroll = !hasUserDefineScrollOffset();
88             if (getAdapter() == null) {
89                 setAdapter(mAdapter);
90             } else {
91                 int currentItemCount = mAdapter.getItemCount();
92                 int lastItemCount = mAdapter.getLastItemSize();
93                 if (currentItemCount > lastItemCount) {
94                     mAdapter.notifyItemRangeInserted(lastItemCount,
95                             currentItemCount - lastItemCount);
96                     mAdapter.notifyItemChanged(lastItemCount - 1);
97                 } else if (currentItemCount < lastItemCount) {
98                     mAdapter.notifyItemRangeRemoved(currentItemCount,
99                             lastItemCount - currentItemCount);
100                     mAdapter.notifyItemChanged(currentItemCount - 1);
101                 } else {
102                     mAdapter.notifyItemChanged(currentItemCount - 1);
103                 }
104             }
105             if (shouldScroll) {
106                 mLayoutManager.scrollToPosition(mAdapter.getItemCount() - 1);
107             }
108         } else {
109             setVisibility(GONE);
110             setAdapter(null);
111         }
112         mAdapter.updateLastItemSize();
113     }
114 
hasUserDefineScrollOffset()115     private boolean hasUserDefineScrollOffset() {
116         final int maxOffset = computeHorizontalScrollRange() - computeHorizontalScrollExtent();
117         return (maxOffset - computeHorizontalScrollOffset() > USER_NO_SCROLL_OFFSET_THRESHOLD);
118     }
119 
onAccessibilityClick(View child)120     private boolean onAccessibilityClick(View child) {
121         int pos = getChildAdapterPosition(child);
122         if (pos != getAdapter().getItemCount() - 1) {
123             mClickListener.accept(pos);
124             return true;
125         }
126         return false;
127     }
128 
onKey(View v, int keyCode, KeyEvent event)129     private boolean onKey(View v, int keyCode, KeyEvent event) {
130         switch (keyCode) {
131             case KeyEvent.KEYCODE_ENTER:
132                 return onAccessibilityClick(v);
133             default:
134                 return false;
135         }
136     }
137 
138     @Override
postUpdate()139     public void postUpdate() {
140     }
141 
onSingleTapUp(MotionEvent e)142     private void onSingleTapUp(MotionEvent e) {
143         View itemView = findChildViewUnder(e.getX(), e.getY());
144         int pos = getChildAdapterPosition(itemView);
145         if (pos != mAdapter.getItemCount() - 1 && pos != -1) {
146             mClickListener.accept(pos);
147         }
148     }
149 
150     private static final class BreadcrumbAdapter
151             extends RecyclerView.Adapter<BreadcrumbHolder> {
152 
153         private final Environment mEnv;
154         private final com.android.documentsui.base.State mState;
155         private final View.OnKeyListener mClickListener;
156         // We keep the old item size so the breadcrumb will only re-render views that are necessary
157         private int mLastItemSize;
158 
BreadcrumbAdapter(com.android.documentsui.base.State state, Environment env, View.OnKeyListener clickListener)159         public BreadcrumbAdapter(com.android.documentsui.base.State state,
160                 Environment env,
161                 View.OnKeyListener clickListener) {
162             mState = state;
163             mEnv = env;
164             mClickListener = clickListener;
165             mLastItemSize = getItemCount();
166         }
167 
168         @Override
onCreateViewHolder(ViewGroup parent, int viewType)169         public BreadcrumbHolder onCreateViewHolder(ViewGroup parent, int viewType) {
170             View v = LayoutInflater.from(parent.getContext())
171                     .inflate(R.layout.navigation_breadcrumb_item, null);
172             return new BreadcrumbHolder(v);
173         }
174 
175         @Override
onBindViewHolder(BreadcrumbHolder holder, int position)176         public void onBindViewHolder(BreadcrumbHolder holder, int position) {
177             final int padding = (int) holder.itemView.getResources()
178                     .getDimension(R.dimen.breadcrumb_item_padding);
179             final int enableColor = holder.itemView.getContext().getColor(R.color.primary);
180             final boolean isFirst = position == 0;
181             // Note that when isFirst is true, there might not be a DocumentInfo on the stack as it
182             // could be an error state screen accessible from the root info.
183             final boolean isLast = position == getItemCount() - 1;
184 
185             holder.mTitle.setText(
186                     isFirst ? mEnv.getCurrentRoot().title : mState.stack.get(position).displayName);
187             holder.mTitle.setTextColor(isLast ? enableColor : holder.mDefaultTextColor);
188             holder.mTitle.setPadding(isFirst ? padding * 3 : padding,
189                     padding, isLast ? padding * 2 : padding, padding);
190             holder.mArrow.setVisibility(isLast ? View.GONE : View.VISIBLE);
191 
192             holder.itemView.setOnKeyListener(mClickListener);
193             holder.setLast(isLast);
194         }
195 
196         @Override
getItemCount()197         public int getItemCount() {
198             // Don't show recents in the breadcrumb.
199             if (mState.stack.isRecents()) {
200                 return 0;
201             }
202             // Continue showing the root title in the breadcrumb for cross-profile error screens.
203             if (mState.supportsCrossProfile()
204                     && mState.stack.size() == 0
205                     && mState.stack.getRoot() != null
206                     && mState.stack.getRoot().supportsCrossProfile()) {
207                 return 1;
208             }
209             return mState.stack.size();
210         }
211 
getLastItemSize()212         public int getLastItemSize() {
213             return mLastItemSize;
214         }
215 
updateLastItemSize()216         public void updateLastItemSize() {
217             mLastItemSize = getItemCount();
218         }
219     }
220 
221     private static final class ClickListener extends GestureDetector
222             implements OnItemTouchListener {
223 
ClickListener(Context context, Consumer<MotionEvent> listener)224         public ClickListener(Context context, Consumer<MotionEvent> listener) {
225             super(context, new SimpleOnGestureListener() {
226                 @Override
227                 public boolean onSingleTapUp(MotionEvent e) {
228                     listener.accept(e);
229                     return true;
230                 }
231             });
232         }
233 
234         @Override
onInterceptTouchEvent(RecyclerView rv, MotionEvent e)235         public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
236             onTouchEvent(e);
237             return false;
238         }
239 
240         @Override
onTouchEvent(RecyclerView rv, MotionEvent e)241         public void onTouchEvent(RecyclerView rv, MotionEvent e) {
242             onTouchEvent(e);
243         }
244 
245         @Override
onRequestDisallowInterceptTouchEvent(boolean disallowIntercept)246         public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
247         }
248     }
249 
250     private static class HorizontalBreadcrumbLinearLayoutManager extends LinearLayoutManager {
251 
252         /**
253          * Disable predictive animations. There is a bug in RecyclerView which causes views that
254          * are being reloaded to pull invalid view holders from the internal recycler stack if the
255          * adapter size has decreased since the ViewHolder was recycled.
256          */
257         @Override
supportsPredictiveItemAnimations()258         public boolean supportsPredictiveItemAnimations() {
259             return false;
260         }
261 
HorizontalBreadcrumbLinearLayoutManager( Context context, int orientation, boolean reverseLayout)262         HorizontalBreadcrumbLinearLayoutManager(
263                 Context context, int orientation, boolean reverseLayout) {
264             super(context, orientation, reverseLayout);
265         }
266     }
267 }
268