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