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