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