1 /*
2  * Copyright (C) 2011 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 package androidx.appcompat.widget;
17 
18 import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
19 
20 import android.animation.Animator;
21 import android.animation.AnimatorListenerAdapter;
22 import android.content.Context;
23 import android.content.res.Configuration;
24 import android.graphics.drawable.Drawable;
25 import android.text.TextUtils;
26 import android.text.TextUtils.TruncateAt;
27 import android.view.Gravity;
28 import android.view.View;
29 import android.view.ViewGroup;
30 import android.view.ViewParent;
31 import android.view.ViewPropertyAnimator;
32 import android.view.accessibility.AccessibilityEvent;
33 import android.view.accessibility.AccessibilityNodeInfo;
34 import android.view.animation.DecelerateInterpolator;
35 import android.view.animation.Interpolator;
36 import android.widget.AdapterView;
37 import android.widget.BaseAdapter;
38 import android.widget.HorizontalScrollView;
39 import android.widget.ImageView;
40 import android.widget.LinearLayout;
41 import android.widget.ListView;
42 import android.widget.Spinner;
43 import android.widget.TextView;
44 
45 import androidx.annotation.RestrictTo;
46 import androidx.appcompat.R;
47 import androidx.appcompat.app.ActionBar;
48 import androidx.appcompat.view.ActionBarPolicy;
49 import androidx.core.view.GravityCompat;
50 
51 import org.jspecify.annotations.NonNull;
52 
53 /**
54  * This widget implements the dynamic action bar tab behavior that can change across different
55  * configurations or circumstances.
56  *
57  */
58 @RestrictTo(LIBRARY_GROUP_PREFIX)
59 public class ScrollingTabContainerView extends HorizontalScrollView
60         implements AdapterView.OnItemSelectedListener {
61 
62     private static final String TAG = "ScrollingTabContainerView";
63     Runnable mTabSelector;
64     private TabClickListener mTabClickListener;
65 
66     LinearLayoutCompat mTabLayout;
67     private Spinner mTabSpinner;
68     private boolean mAllowCollapse;
69 
70     int mMaxTabWidth;
71     int mStackedTabMaxWidth;
72     private int mContentHeight;
73     private int mSelectedTabIndex;
74 
75     protected ViewPropertyAnimator mVisibilityAnim;
76     protected final VisibilityAnimListener mVisAnimListener = new VisibilityAnimListener();
77 
78     private static final Interpolator sAlphaInterpolator = new DecelerateInterpolator();
79 
80     private static final int FADE_DURATION = 200;
81 
ScrollingTabContainerView(@onNull Context context)82     public ScrollingTabContainerView(@NonNull Context context) {
83         super(context);
84 
85         setHorizontalScrollBarEnabled(false);
86 
87         ActionBarPolicy abp = ActionBarPolicy.get(context);
88         setContentHeight(abp.getTabContainerHeight());
89         mStackedTabMaxWidth = abp.getStackedTabMaxWidth();
90 
91         mTabLayout = createTabLayout();
92         addView(mTabLayout, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
93                 ViewGroup.LayoutParams.MATCH_PARENT));
94     }
95 
96     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)97     public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
98         final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
99         final boolean lockedExpanded = widthMode == MeasureSpec.EXACTLY;
100         setFillViewport(lockedExpanded);
101 
102         final int childCount = mTabLayout.getChildCount();
103         if (childCount > 1 &&
104                 (widthMode == MeasureSpec.EXACTLY || widthMode == MeasureSpec.AT_MOST)) {
105             if (childCount > 2) {
106                 mMaxTabWidth = (int) (MeasureSpec.getSize(widthMeasureSpec) * 0.4f);
107             } else {
108                 mMaxTabWidth = MeasureSpec.getSize(widthMeasureSpec) / 2;
109             }
110             mMaxTabWidth = Math.min(mMaxTabWidth, mStackedTabMaxWidth);
111         } else {
112             mMaxTabWidth = -1;
113         }
114 
115         heightMeasureSpec = MeasureSpec.makeMeasureSpec(mContentHeight, MeasureSpec.EXACTLY);
116 
117         final boolean canCollapse = !lockedExpanded && mAllowCollapse;
118 
119         if (canCollapse) {
120             // See if we should expand
121             mTabLayout.measure(MeasureSpec.UNSPECIFIED, heightMeasureSpec);
122             if (mTabLayout.getMeasuredWidth() > MeasureSpec.getSize(widthMeasureSpec)) {
123                 performCollapse();
124             } else {
125                 performExpand();
126             }
127         } else {
128             performExpand();
129         }
130 
131         final int oldWidth = getMeasuredWidth();
132         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
133         final int newWidth = getMeasuredWidth();
134 
135         if (lockedExpanded && oldWidth != newWidth) {
136             // Recenter the tab display if we're at a new (scrollable) size.
137             setTabSelected(mSelectedTabIndex);
138         }
139     }
140 
141     /**
142      * Indicates whether this view is collapsed into a dropdown menu instead
143      * of traditional tabs.
144      * @return true if showing as a spinner
145      */
isCollapsed()146     private boolean isCollapsed() {
147         return mTabSpinner != null && mTabSpinner.getParent() == this;
148     }
149 
setAllowCollapse(boolean allowCollapse)150     public void setAllowCollapse(boolean allowCollapse) {
151         mAllowCollapse = allowCollapse;
152     }
153 
performCollapse()154     private void performCollapse() {
155         if (isCollapsed()) return;
156 
157         if (mTabSpinner == null) {
158             mTabSpinner = createSpinner();
159         }
160         removeView(mTabLayout);
161         addView(mTabSpinner, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
162                 ViewGroup.LayoutParams.MATCH_PARENT));
163         if (mTabSpinner.getAdapter() == null) {
164             mTabSpinner.setAdapter(new TabAdapter());
165         }
166         if (mTabSelector != null) {
167             removeCallbacks(mTabSelector);
168             mTabSelector = null;
169         }
170         mTabSpinner.setSelection(mSelectedTabIndex);
171     }
172 
performExpand()173     private boolean performExpand() {
174         if (!isCollapsed()) return false;
175 
176         removeView(mTabSpinner);
177         addView(mTabLayout, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
178                 ViewGroup.LayoutParams.MATCH_PARENT));
179         setTabSelected(mTabSpinner.getSelectedItemPosition());
180         return false;
181     }
182 
setTabSelected(int position)183     public void setTabSelected(int position) {
184         mSelectedTabIndex = position;
185         final int tabCount = mTabLayout.getChildCount();
186         for (int i = 0; i < tabCount; i++) {
187             final View child = mTabLayout.getChildAt(i);
188             final boolean isSelected = i == position;
189             child.setSelected(isSelected);
190             if (isSelected) {
191                 animateToTab(position);
192             }
193         }
194         if (mTabSpinner != null && position >= 0) {
195             mTabSpinner.setSelection(position);
196         }
197     }
198 
setContentHeight(int contentHeight)199     public void setContentHeight(int contentHeight) {
200         mContentHeight = contentHeight;
201         requestLayout();
202     }
203 
createTabLayout()204     private LinearLayoutCompat createTabLayout() {
205         final LinearLayoutCompat tabLayout = new LinearLayoutCompat(getContext(), null,
206                 R.attr.actionBarTabBarStyle);
207         tabLayout.setMeasureWithLargestChildEnabled(true);
208         tabLayout.setGravity(Gravity.CENTER);
209         tabLayout.setLayoutParams(new LinearLayoutCompat.LayoutParams(
210                 LinearLayoutCompat.LayoutParams.WRAP_CONTENT, LinearLayoutCompat.LayoutParams.MATCH_PARENT));
211         return tabLayout;
212     }
213 
createSpinner()214     private Spinner createSpinner() {
215         final Spinner spinner = new AppCompatSpinner(getContext(), null,
216                 R.attr.actionDropDownStyle);
217         spinner.setLayoutParams(new LinearLayoutCompat.LayoutParams(
218                 LinearLayoutCompat.LayoutParams.WRAP_CONTENT,
219                 LinearLayoutCompat.LayoutParams.MATCH_PARENT));
220         spinner.setOnItemSelectedListener(this);
221         return spinner;
222     }
223 
224     @Override
onConfigurationChanged(Configuration newConfig)225     protected void onConfigurationChanged(Configuration newConfig) {
226         super.onConfigurationChanged(newConfig);
227 
228         ActionBarPolicy abp = ActionBarPolicy.get(getContext());
229         // Action bar can change size on configuration changes.
230         // Reread the desired height from the theme-specified style.
231         setContentHeight(abp.getTabContainerHeight());
232         mStackedTabMaxWidth = abp.getStackedTabMaxWidth();
233     }
234 
animateToVisibility(int visibility)235     public void animateToVisibility(int visibility) {
236         if (mVisibilityAnim != null) {
237             mVisibilityAnim.cancel();
238         }
239         if (visibility == VISIBLE) {
240             if (getVisibility() != VISIBLE) {
241                 setAlpha(0f);
242             }
243 
244             ViewPropertyAnimator anim = animate().alpha(1f);
245             anim.setDuration(FADE_DURATION);
246 
247             anim.setInterpolator(sAlphaInterpolator);
248             anim.setListener(mVisAnimListener.withFinalVisibility(anim, visibility));
249             anim.start();
250         } else {
251             ViewPropertyAnimator anim = animate().alpha(0f);
252             anim.setDuration(FADE_DURATION);
253 
254             anim.setInterpolator(sAlphaInterpolator);
255             anim.setListener(mVisAnimListener.withFinalVisibility(anim, visibility));
256             anim.start();
257         }
258     }
259 
animateToTab(final int position)260     public void animateToTab(final int position) {
261         final View tabView = mTabLayout.getChildAt(position);
262         if (mTabSelector != null) {
263             removeCallbacks(mTabSelector);
264         }
265         mTabSelector = new Runnable() {
266             @Override
267             public void run() {
268                 final int scrollPos = tabView.getLeft() - (getWidth() - tabView.getWidth()) / 2;
269                 smoothScrollTo(scrollPos, 0);
270                 mTabSelector = null;
271             }
272         };
273         post(mTabSelector);
274     }
275 
276     @Override
onAttachedToWindow()277     public void onAttachedToWindow() {
278         super.onAttachedToWindow();
279         if (mTabSelector != null) {
280             // Re-post the selector we saved
281             post(mTabSelector);
282         }
283     }
284 
285     @Override
onDetachedFromWindow()286     public void onDetachedFromWindow() {
287         super.onDetachedFromWindow();
288         if (mTabSelector != null) {
289             removeCallbacks(mTabSelector);
290         }
291     }
292 
createTabView(ActionBar.Tab tab, boolean forAdapter)293     TabView createTabView(ActionBar.Tab tab, boolean forAdapter) {
294         final TabView tabView = new TabView(getContext(), tab, forAdapter);
295         if (forAdapter) {
296             tabView.setBackgroundDrawable(null);
297             tabView.setLayoutParams(new ListView.LayoutParams(ListView.LayoutParams.MATCH_PARENT,
298                     mContentHeight));
299         } else {
300             tabView.setFocusable(true);
301 
302             if (mTabClickListener == null) {
303                 mTabClickListener = new TabClickListener();
304             }
305             tabView.setOnClickListener(mTabClickListener);
306         }
307         return tabView;
308     }
309 
addTab(ActionBar.Tab tab, boolean setSelected)310     public void addTab(ActionBar.Tab tab, boolean setSelected) {
311         TabView tabView = createTabView(tab, false);
312         mTabLayout.addView(tabView, new LinearLayoutCompat.LayoutParams(0,
313                 LayoutParams.MATCH_PARENT, 1));
314         if (mTabSpinner != null) {
315             ((TabAdapter) mTabSpinner.getAdapter()).notifyDataSetChanged();
316         }
317         if (setSelected) {
318             tabView.setSelected(true);
319         }
320         if (mAllowCollapse) {
321             requestLayout();
322         }
323     }
324 
addTab(ActionBar.Tab tab, int position, boolean setSelected)325     public void addTab(ActionBar.Tab tab, int position, boolean setSelected) {
326         final TabView tabView = createTabView(tab, false);
327         mTabLayout.addView(tabView, position, new LinearLayoutCompat.LayoutParams(
328                 0, LayoutParams.MATCH_PARENT, 1));
329         if (mTabSpinner != null) {
330             ((TabAdapter) mTabSpinner.getAdapter()).notifyDataSetChanged();
331         }
332         if (setSelected) {
333             tabView.setSelected(true);
334         }
335         if (mAllowCollapse) {
336             requestLayout();
337         }
338     }
339 
updateTab(int position)340     public void updateTab(int position) {
341         ((TabView) mTabLayout.getChildAt(position)).update();
342         if (mTabSpinner != null) {
343             ((TabAdapter) mTabSpinner.getAdapter()).notifyDataSetChanged();
344         }
345         if (mAllowCollapse) {
346             requestLayout();
347         }
348     }
349 
removeTabAt(int position)350     public void removeTabAt(int position) {
351         mTabLayout.removeViewAt(position);
352         if (mTabSpinner != null) {
353             ((TabAdapter) mTabSpinner.getAdapter()).notifyDataSetChanged();
354         }
355         if (mAllowCollapse) {
356             requestLayout();
357         }
358     }
359 
removeAllTabs()360     public void removeAllTabs() {
361         mTabLayout.removeAllViews();
362         if (mTabSpinner != null) {
363             ((TabAdapter) mTabSpinner.getAdapter()).notifyDataSetChanged();
364         }
365         if (mAllowCollapse) {
366             requestLayout();
367         }
368     }
369 
370     @Override
onItemSelected(AdapterView<?> adapterView, View view, int position, long id)371     public void onItemSelected(AdapterView<?> adapterView, View view, int position, long id) {
372         TabView tabView = (TabView) view;
373         tabView.getTab().select();
374     }
375 
376     @Override
onNothingSelected(AdapterView<?> adapterView)377     public void onNothingSelected(AdapterView<?> adapterView) {
378         // no-op
379     }
380 
381     private class TabView extends LinearLayout {
382         private final int[] BG_ATTRS = {
383                 android.R.attr.background
384         };
385 
386         private ActionBar.Tab mTab;
387         private TextView mTextView;
388         private ImageView mIconView;
389         private View mCustomView;
390 
391         // Class name may be obfuscated by Proguard. Hardcode the string for accessibility usage.
392         private static final String ACCESSIBILITY_CLASS_NAME =
393                 "androidx.appcompat.app.ActionBar$Tab";
394 
TabView(Context context, ActionBar.Tab tab, boolean forList)395         public TabView(Context context, ActionBar.Tab tab, boolean forList) {
396             super(context, null, R.attr.actionBarTabStyle);
397             mTab = tab;
398 
399             TintTypedArray a = TintTypedArray.obtainStyledAttributes(context, null, BG_ATTRS,
400                     R.attr.actionBarTabStyle, 0);
401             if (a.hasValue(0)) {
402                 setBackgroundDrawable(a.getDrawable(0));
403             }
404             a.recycle();
405 
406             if (forList) {
407                 setGravity(GravityCompat.START | Gravity.CENTER_VERTICAL);
408             }
409 
410             update();
411         }
412 
bindTab(ActionBar.Tab tab)413         public void bindTab(ActionBar.Tab tab) {
414             mTab = tab;
415             update();
416         }
417 
418         @Override
setSelected(boolean selected)419         public void setSelected(boolean selected) {
420             final boolean changed = (isSelected() != selected);
421             super.setSelected(selected);
422             if (changed && selected) {
423                 sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
424             }
425         }
426 
427         @Override
onInitializeAccessibilityEvent(AccessibilityEvent event)428         public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
429             super.onInitializeAccessibilityEvent(event);
430             // This view masquerades as an action bar tab.
431             event.setClassName(ACCESSIBILITY_CLASS_NAME);
432         }
433 
434         @Override
onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info)435         public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
436             super.onInitializeAccessibilityNodeInfo(info);
437 
438             // This view masquerades as an action bar tab.
439             info.setClassName(ACCESSIBILITY_CLASS_NAME);
440         }
441 
442         @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)443         public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
444             super.onMeasure(widthMeasureSpec, heightMeasureSpec);
445 
446             // Re-measure if we went beyond our maximum size.
447             if (mMaxTabWidth > 0 && getMeasuredWidth() > mMaxTabWidth) {
448                 super.onMeasure(MeasureSpec.makeMeasureSpec(mMaxTabWidth, MeasureSpec.EXACTLY),
449                         heightMeasureSpec);
450             }
451         }
452 
update()453         public void update() {
454             final ActionBar.Tab tab = mTab;
455             final View custom = tab.getCustomView();
456             if (custom != null) {
457                 final ViewParent customParent = custom.getParent();
458                 if (customParent != this) {
459                     if (customParent != null) ((ViewGroup) customParent).removeView(custom);
460                     addView(custom);
461                 }
462                 mCustomView = custom;
463                 if (mTextView != null) mTextView.setVisibility(GONE);
464                 if (mIconView != null) {
465                     mIconView.setVisibility(GONE);
466                     mIconView.setImageDrawable(null);
467                 }
468             } else {
469                 if (mCustomView != null) {
470                     removeView(mCustomView);
471                     mCustomView = null;
472                 }
473 
474                 final Drawable icon = tab.getIcon();
475                 final CharSequence text = tab.getText();
476 
477                 if (icon != null) {
478                     if (mIconView == null) {
479                         ImageView iconView = new AppCompatImageView(getContext());
480                         LayoutParams lp = new LayoutParams(LayoutParams.WRAP_CONTENT,
481                                 LayoutParams.WRAP_CONTENT);
482                         lp.gravity = Gravity.CENTER_VERTICAL;
483                         iconView.setLayoutParams(lp);
484                         addView(iconView, 0);
485                         mIconView = iconView;
486                     }
487                     mIconView.setImageDrawable(icon);
488                     mIconView.setVisibility(VISIBLE);
489                 } else if (mIconView != null) {
490                     mIconView.setVisibility(GONE);
491                     mIconView.setImageDrawable(null);
492                 }
493 
494                 final boolean hasText = !TextUtils.isEmpty(text);
495                 if (hasText) {
496                     if (mTextView == null) {
497                         TextView textView = new AppCompatTextView(getContext(), null,
498                                 R.attr.actionBarTabTextStyle);
499                         textView.setEllipsize(TruncateAt.END);
500                         LayoutParams lp = new LayoutParams(LayoutParams.WRAP_CONTENT,
501                                 LayoutParams.WRAP_CONTENT);
502                         lp.gravity = Gravity.CENTER_VERTICAL;
503                         textView.setLayoutParams(lp);
504                         addView(textView);
505                         mTextView = textView;
506                     }
507                     mTextView.setText(text);
508                     mTextView.setVisibility(VISIBLE);
509                 } else if (mTextView != null) {
510                     mTextView.setVisibility(GONE);
511                     mTextView.setText(null);
512                 }
513 
514                 if (mIconView != null) {
515                     mIconView.setContentDescription(tab.getContentDescription());
516                 }
517                 TooltipCompat.setTooltipText(this, hasText ? null : tab.getContentDescription());
518             }
519         }
520 
getTab()521         public ActionBar.Tab getTab() {
522             return mTab;
523         }
524     }
525 
526     private class TabAdapter extends BaseAdapter {
TabAdapter()527         TabAdapter() {
528         }
529 
530         @Override
getCount()531         public int getCount() {
532             return mTabLayout.getChildCount();
533         }
534 
535         @Override
getItem(int position)536         public Object getItem(int position) {
537             return ((TabView) mTabLayout.getChildAt(position)).getTab();
538         }
539 
540         @Override
getItemId(int position)541         public long getItemId(int position) {
542             return position;
543         }
544 
545         @Override
getView(int position, View convertView, ViewGroup parent)546         public View getView(int position, View convertView, ViewGroup parent) {
547             if (convertView == null) {
548                 convertView = createTabView((ActionBar.Tab) getItem(position), true);
549             } else {
550                 ((TabView) convertView).bindTab((ActionBar.Tab) getItem(position));
551             }
552             return convertView;
553         }
554     }
555 
556     private class TabClickListener implements OnClickListener {
TabClickListener()557         TabClickListener() {
558         }
559 
560         @Override
onClick(View view)561         public void onClick(View view) {
562             TabView tabView = (TabView) view;
563             tabView.getTab().select();
564             final int tabCount = mTabLayout.getChildCount();
565             for (int i = 0; i < tabCount; i++) {
566                 final View child = mTabLayout.getChildAt(i);
567                 child.setSelected(child == view);
568             }
569         }
570     }
571 
572     protected class VisibilityAnimListener extends AnimatorListenerAdapter {
573         private boolean mCanceled = false;
574         private int mFinalVisibility;
575 
withFinalVisibility(ViewPropertyAnimator animation, int visibility)576         public VisibilityAnimListener withFinalVisibility(ViewPropertyAnimator animation,
577                 int visibility) {
578             mFinalVisibility = visibility;
579             mVisibilityAnim = animation;
580             return this;
581         }
582 
583         @Override
onAnimationStart(Animator animator)584         public void onAnimationStart(Animator animator) {
585             setVisibility(VISIBLE);
586             mCanceled = false;
587         }
588 
589         @Override
onAnimationEnd(Animator animator)590         public void onAnimationEnd(Animator animator) {
591             if (mCanceled) return;
592 
593             mVisibilityAnim = null;
594             setVisibility(mFinalVisibility);
595         }
596 
597         @Override
onAnimationCancel(Animator animator)598         public void onAnimationCancel(Animator animator) {
599             mCanceled = true;
600         }
601     }
602 }
603 
604