1 /* 2 * Copyright (C) 2018 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.queries; 18 19 import android.animation.ObjectAnimator; 20 import android.content.Context; 21 import android.os.Bundle; 22 import android.view.LayoutInflater; 23 import android.view.View; 24 import android.view.ViewGroup; 25 import android.widget.HorizontalScrollView; 26 27 import androidx.annotation.NonNull; 28 import androidx.annotation.Nullable; 29 import androidx.annotation.VisibleForTesting; 30 31 import com.android.documentsui.IconUtils; 32 import com.android.documentsui.MetricConsts; 33 import com.android.documentsui.R; 34 import com.android.documentsui.base.MimeTypes; 35 import com.android.documentsui.base.Shared; 36 37 import com.google.android.material.chip.Chip; 38 import com.google.common.primitives.Ints; 39 40 import java.util.ArrayList; 41 import java.util.Arrays; 42 import java.util.Collections; 43 import java.util.Comparator; 44 import java.util.HashMap; 45 import java.util.HashSet; 46 import java.util.List; 47 import java.util.Map; 48 import java.util.Set; 49 50 /** 51 * Manages search chip behavior. 52 */ 53 public class SearchChipViewManager { 54 55 private static final int CHIP_MOVE_ANIMATION_DURATION = 250; 56 57 private static final int TYPE_IMAGES = MetricConsts.TYPE_CHIP_IMAGES;; 58 private static final int TYPE_DOCUMENTS = MetricConsts.TYPE_CHIP_DOCS; 59 private static final int TYPE_AUDIO = MetricConsts.TYPE_CHIP_AUDIOS; 60 private static final int TYPE_VIDEOS = MetricConsts.TYPE_CHIP_VIDEOS; 61 62 private static final ChipComparator CHIP_COMPARATOR = new ChipComparator(); 63 64 // we will get the icon drawable with the first mimeType 65 private static final String[] IMAGES_MIMETYPES = new String[]{"image/*"}; 66 private static final String[] VIDEOS_MIMETYPES = new String[]{"video/*"}; 67 private static final String[] AUDIO_MIMETYPES = 68 new String[]{"audio/*", "application/ogg", "application/x-flac"}; 69 private static final String[] DOCUMENTS_MIMETYPES = new String[]{"application/*", "text/*"}; 70 71 private static final Map<Integer, SearchChipData> sChipItems = new HashMap<>(); 72 73 private final ViewGroup mChipGroup; 74 private final List<Integer> mDefaultChipTypes = new ArrayList<>(); 75 private SearchChipViewManagerListener mListener; 76 private String[] mCurrentUpdateMimeTypes; 77 private boolean mIsFirstUpdateChipsReady; 78 79 @VisibleForTesting 80 Set<SearchChipData> mCheckedChipItems = new HashSet<>(); 81 82 static { sChipItems.put(TYPE_IMAGES, new SearchChipData(TYPE_IMAGES, R.string.chip_title_images, IMAGES_MIMETYPES))83 sChipItems.put(TYPE_IMAGES, 84 new SearchChipData(TYPE_IMAGES, R.string.chip_title_images, IMAGES_MIMETYPES)); sChipItems.put(TYPE_DOCUMENTS, new SearchChipData(TYPE_DOCUMENTS, R.string.chip_title_documents, DOCUMENTS_MIMETYPES))85 sChipItems.put(TYPE_DOCUMENTS, 86 new SearchChipData(TYPE_DOCUMENTS, R.string.chip_title_documents, 87 DOCUMENTS_MIMETYPES)); sChipItems.put(TYPE_AUDIO, new SearchChipData(TYPE_AUDIO, R.string.chip_title_audio, AUDIO_MIMETYPES))88 sChipItems.put(TYPE_AUDIO, 89 new SearchChipData(TYPE_AUDIO, R.string.chip_title_audio, AUDIO_MIMETYPES)); sChipItems.put(TYPE_VIDEOS, new SearchChipData(TYPE_VIDEOS, R.string.chip_title_videos, VIDEOS_MIMETYPES))90 sChipItems.put(TYPE_VIDEOS, 91 new SearchChipData(TYPE_VIDEOS, R.string.chip_title_videos, VIDEOS_MIMETYPES)); 92 } 93 SearchChipViewManager(@onNull ViewGroup chipGroup)94 public SearchChipViewManager(@NonNull ViewGroup chipGroup) { 95 mChipGroup = chipGroup; 96 } 97 98 /** 99 * Restore the checked chip items by the saved state. 100 * 101 * @param savedState the saved state to restore. 102 */ restoreCheckedChipItems(Bundle savedState)103 public void restoreCheckedChipItems(Bundle savedState) { 104 final int[] chipTypes = savedState.getIntArray(Shared.EXTRA_QUERY_CHIPS); 105 if (chipTypes != null) { 106 clearCheckedChips(); 107 for (int chipType : chipTypes) { 108 final SearchChipData chipData = sChipItems.get(chipType); 109 mCheckedChipItems.add(chipData); 110 setCheckedChip(chipData.getChipType()); 111 } 112 } 113 } 114 115 /** 116 * Set the visibility of the chips row. If the count of chips is less than 2, 117 * we will hide the chips row. 118 * 119 * @param show the value to show/hide the chips row. 120 */ setChipsRowVisible(boolean show)121 public void setChipsRowVisible(boolean show) { 122 // if there is only one matched chip, hide the chip group. 123 mChipGroup.setVisibility(show && mChipGroup.getChildCount() > 1 ? View.VISIBLE : View.GONE); 124 } 125 126 /** 127 * Check Whether the checked item list has contents. 128 * 129 * @return True, if the checked item list is not empty. Otherwise, return false. 130 */ hasCheckedItems()131 public boolean hasCheckedItems() { 132 return !mCheckedChipItems.isEmpty(); 133 } 134 135 /** 136 * Clear the checked state of Chips and the checked list. 137 */ clearCheckedChips()138 public void clearCheckedChips() { 139 final int count = mChipGroup.getChildCount(); 140 for (int i = 0; i < count; i++) { 141 Chip child = (Chip) mChipGroup.getChildAt(i); 142 setChipChecked(child, false /* isChecked */); 143 } 144 mCheckedChipItems.clear(); 145 } 146 147 /** 148 * Get the mime types of checked chips 149 * 150 * @return the string array of mime types 151 */ getCheckedMimeTypes()152 public String[] getCheckedMimeTypes() { 153 final ArrayList<String> args = new ArrayList<>(); 154 for (SearchChipData data : mCheckedChipItems) { 155 for (String mimeType : data.getMimeTypes()) { 156 args.add(mimeType); 157 } 158 } 159 return args.toArray(new String[0]); 160 } 161 162 /** 163 * Called when owning activity is saving state to be used to restore state during creation. 164 * 165 * @param state Bundle to save state 166 */ onSaveInstanceState(Bundle state)167 public void onSaveInstanceState(Bundle state) { 168 List<Integer> checkedChipList = new ArrayList<>(); 169 170 for (SearchChipData item : mCheckedChipItems) { 171 checkedChipList.add(item.getChipType()); 172 } 173 174 if (checkedChipList.size() > 0) { 175 state.putIntArray(Shared.EXTRA_QUERY_CHIPS, Ints.toArray(checkedChipList)); 176 } 177 } 178 179 /** 180 * Initialize the search chips base on the mime types. 181 * 182 * @param acceptMimeTypes use this values to filter chips 183 */ initChipSets(String[] acceptMimeTypes)184 public void initChipSets(String[] acceptMimeTypes) { 185 mDefaultChipTypes.clear(); 186 for (SearchChipData chipData : sChipItems.values()) { 187 final String[] mimeTypes = chipData.getMimeTypes(); 188 final boolean isMatched = MimeTypes.mimeMatches(acceptMimeTypes, mimeTypes); 189 if (isMatched) { 190 mDefaultChipTypes.add(chipData.getChipType()); 191 } 192 } 193 } 194 195 /** 196 * Update the search chips base on the mime types. 197 * 198 * @param acceptMimeTypes use this values to filter chips 199 */ updateChips(String[] acceptMimeTypes)200 public void updateChips(String[] acceptMimeTypes) { 201 if (mIsFirstUpdateChipsReady && Arrays.equals(mCurrentUpdateMimeTypes, acceptMimeTypes)) { 202 return; 203 } 204 205 final Context context = mChipGroup.getContext(); 206 mChipGroup.removeAllViews(); 207 208 final LayoutInflater inflater = LayoutInflater.from(context); 209 for (Integer chipType : mDefaultChipTypes) { 210 final SearchChipData chipData = sChipItems.get(chipType); 211 final String[] mimeTypes = chipData.getMimeTypes(); 212 final boolean isMatched = MimeTypes.mimeMatches(acceptMimeTypes, mimeTypes); 213 if (isMatched) { 214 addChipToGroup(mChipGroup, chipData, inflater); 215 } 216 } 217 reorderCheckedChips(null /* clickedChip */, false /* hasAnim */); 218 mIsFirstUpdateChipsReady = true; 219 mCurrentUpdateMimeTypes = acceptMimeTypes; 220 if (mChipGroup.getChildCount() < 2) { 221 mChipGroup.setVisibility(View.GONE); 222 } 223 } 224 addChipToGroup(ViewGroup group, SearchChipData data, LayoutInflater inflater)225 private void addChipToGroup(ViewGroup group, SearchChipData data, LayoutInflater inflater) { 226 Chip chip = (Chip) inflater.inflate(R.layout.search_chip_item, mChipGroup, false); 227 bindChip(chip, data); 228 group.addView(chip); 229 } 230 231 /** 232 * Mirror chip group here for another chip group 233 * 234 * @param chipGroup target view group for mirror 235 */ bindMirrorGroup(ViewGroup chipGroup)236 public void bindMirrorGroup(ViewGroup chipGroup) { 237 final int size = mChipGroup.getChildCount(); 238 if (size <= 1) { 239 chipGroup.setVisibility(View.GONE); 240 return; 241 } 242 243 chipGroup.setVisibility(View.VISIBLE); 244 chipGroup.removeAllViews(); 245 final LayoutInflater inflater = LayoutInflater.from(chipGroup.getContext()); 246 for (int i = 0; i < size; i++) { 247 Chip child = (Chip) mChipGroup.getChildAt(i); 248 SearchChipData item = (SearchChipData) child.getTag(); 249 addChipToGroup(chipGroup, item, inflater); 250 } 251 } 252 253 /** 254 * Click behavior handle here when mirror chip clicked. 255 * 256 * @param data SearchChipData synced in mirror group 257 */ onMirrorChipClick(SearchChipData data)258 public void onMirrorChipClick(SearchChipData data) { 259 for (int i = 0, size = mChipGroup.getChildCount(); i < size; i++) { 260 Chip chip = (Chip) mChipGroup.getChildAt(i); 261 if (chip.getTag().equals(data)) { 262 chip.setChecked(!chip.isChecked()); 263 onChipClick(chip); 264 return; 265 } 266 } 267 } 268 269 /** 270 * Set the listener. 271 * 272 * @param listener the listener 273 */ setSearchChipViewManagerListener(SearchChipViewManagerListener listener)274 public void setSearchChipViewManagerListener(SearchChipViewManagerListener listener) { 275 mListener = listener; 276 } 277 setChipChecked(Chip chip, boolean isChecked)278 private static void setChipChecked(Chip chip, boolean isChecked) { 279 chip.setChecked(isChecked); 280 chip.setChipIconVisible(!isChecked); 281 } 282 setCheckedChip(int chipType)283 private void setCheckedChip(int chipType) { 284 final int count = mChipGroup.getChildCount(); 285 for (int i = 0; i < count; i++) { 286 Chip child = (Chip) mChipGroup.getChildAt(i); 287 SearchChipData item = (SearchChipData) child.getTag(); 288 if (item.getChipType() == chipType) { 289 setChipChecked(child, true /* isChecked */); 290 break; 291 } 292 } 293 } 294 onChipClick(View v)295 private void onChipClick(View v) { 296 final Chip chip = (Chip) v; 297 298 // We need to show/hide the chip icon in our design. 299 // When we show/hide the chip icon or do reorder animation, 300 // the ripple effect will be interrupted. So, skip ripple 301 // effect when the chip is clicked. 302 chip.getBackground().setVisible(false /* visible */, false /* restart */); 303 304 final SearchChipData item = (SearchChipData) chip.getTag(); 305 if (chip.isChecked()) { 306 mCheckedChipItems.add(item); 307 } else { 308 mCheckedChipItems.remove(item); 309 } 310 311 setChipChecked(chip, chip.isChecked()); 312 reorderCheckedChips(chip, true /* hasAnim */); 313 314 if (mListener != null) { 315 mListener.onChipCheckStateChanged(v); 316 } 317 } 318 bindChip(Chip chip, SearchChipData chipData)319 private void bindChip(Chip chip, SearchChipData chipData) { 320 chip.setTag(chipData); 321 chip.setText(mChipGroup.getContext().getString(chipData.getTitleRes())); 322 // get the icon drawable with the first mimeType 323 chip.setChipIcon( 324 IconUtils.loadMimeIcon(mChipGroup.getContext(), chipData.getMimeTypes()[0])); 325 chip.setOnClickListener(this::onChipClick); 326 327 if (mCheckedChipItems.contains(chipData)) { 328 setChipChecked(chip, true); 329 } 330 } 331 332 /** 333 * Reorder the chips in chip group. The checked chip has higher order. 334 * 335 * @param clickedChip the clicked chip, may be null. 336 * @param hasAnim if true, play move animation. Otherwise, not. 337 */ reorderCheckedChips(@ullable Chip clickedChip, boolean hasAnim)338 private void reorderCheckedChips(@Nullable Chip clickedChip, boolean hasAnim) { 339 final ArrayList<Chip> chipList = new ArrayList<>(); 340 final int count = mChipGroup.getChildCount(); 341 342 // if the size of chips is less than 2, no need to reorder chips 343 if (count < 2) { 344 return; 345 } 346 347 Chip item; 348 // get the default order 349 for (int i = 0; i < count; i++) { 350 item = (Chip) mChipGroup.getChildAt(i); 351 chipList.add(item); 352 } 353 354 // sort chips 355 Collections.sort(chipList, CHIP_COMPARATOR); 356 357 if (isChipOrderMatched(mChipGroup, chipList)) { 358 // the order of chips is not changed 359 return; 360 } 361 362 final int chipSpacing = mChipGroup.getPaddingEnd(); 363 final boolean isRtl = mChipGroup.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; 364 float lastX = isRtl ? mChipGroup.getWidth() - chipSpacing : chipSpacing; 365 366 // remove all chips except current clicked chip to avoid losing 367 // accessibility focus. 368 for (int i = count - 1; i >= 0; i--) { 369 item = (Chip) mChipGroup.getChildAt(i); 370 if (!item.equals(clickedChip)) { 371 mChipGroup.removeView(item); 372 } 373 } 374 375 // add sorted chips 376 for (int i = 0; i < count; i++) { 377 item = chipList.get(i); 378 if (!item.equals(clickedChip)) { 379 mChipGroup.addView(item, i); 380 } 381 } 382 383 if (hasAnim && mChipGroup.isAttachedToWindow()) { 384 // start animation 385 for (Chip chip : chipList) { 386 if (isRtl) { 387 lastX -= chip.getMeasuredWidth(); 388 } 389 390 ObjectAnimator animator = ObjectAnimator.ofFloat(chip, "x", chip.getX(), lastX); 391 392 if (isRtl) { 393 lastX -= chipSpacing; 394 } else { 395 lastX += chip.getMeasuredWidth() + chipSpacing; 396 } 397 animator.setDuration(CHIP_MOVE_ANIMATION_DURATION); 398 animator.start(); 399 } 400 401 // Let the first checked chip can be shown. 402 View parent = (View) mChipGroup.getParent(); 403 if (parent instanceof HorizontalScrollView) { 404 final int scrollToX = isRtl ? parent.getWidth() : 0; 405 ((HorizontalScrollView) parent).smoothScrollTo(scrollToX, 0); 406 } 407 } 408 } 409 isChipOrderMatched(ViewGroup chipGroup, ArrayList<Chip> chipList)410 private static boolean isChipOrderMatched(ViewGroup chipGroup, ArrayList<Chip> chipList) { 411 if (chipGroup == null || chipList == null) { 412 return false; 413 } 414 415 final int chipCount = chipList.size(); 416 if (chipGroup.getChildCount() != chipCount) { 417 return false; 418 } 419 for (int i = 0; i < chipCount; i++) { 420 if (!chipList.get(i).equals(chipGroup.getChildAt(i))) { 421 return false; 422 } 423 } 424 return true; 425 } 426 427 /** 428 * The listener of SearchChipViewManager. 429 */ 430 public interface SearchChipViewManagerListener { 431 /** 432 * It will be triggered when the checked state of chips changes. 433 */ onChipCheckStateChanged(View v)434 void onChipCheckStateChanged(View v); 435 } 436 437 private static class ChipComparator implements Comparator<Chip> { 438 439 @Override compare(Chip lhs, Chip rhs)440 public int compare(Chip lhs, Chip rhs) { 441 return (lhs.isChecked() == rhs.isChecked()) ? 0 : (lhs.isChecked() ? -1 : 1); 442 } 443 } 444 } 445