1 /*
2  * Copyright (C) 2006 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 android.widget;
18 
19 import static android.view.flags.Flags.enableArrowIconOnHoverWhenClickable;
20 import static android.view.flags.Flags.FLAG_ENABLE_ARROW_ICON_ON_HOVER_WHEN_CLICKABLE;
21 
22 import android.annotation.DrawableRes;
23 import android.annotation.FlaggedApi;
24 import android.annotation.Nullable;
25 import android.compat.annotation.UnsupportedAppUsage;
26 import android.content.Context;
27 import android.content.res.TypedArray;
28 import android.graphics.Canvas;
29 import android.graphics.Rect;
30 import android.graphics.drawable.Drawable;
31 import android.os.Build;
32 import android.util.AttributeSet;
33 import android.view.MotionEvent;
34 import android.view.PointerIcon;
35 import android.view.View;
36 import android.view.View.OnFocusChangeListener;
37 import android.view.ViewGroup;
38 import android.view.accessibility.AccessibilityEvent;
39 
40 import com.android.internal.R;
41 
42 /**
43  *
44  * Displays a list of tab labels representing each page in the parent's tab
45  * collection.
46  * <p>
47  * The container object for this widget is {@link android.widget.TabHost TabHost}.
48  * When the user selects a tab, this object sends a message to the parent
49  * container, TabHost, to tell it to switch the displayed page. You typically
50  * won't use many methods directly on this object. The container TabHost is
51  * used to add labels, add the callback handler, and manage callbacks. You
52  * might call this object to iterate the list of tabs, or to tweak the layout
53  * of the tab list, but most methods should be called on the containing TabHost
54  * object.
55  *
56  * @attr ref android.R.styleable#TabWidget_divider
57  * @attr ref android.R.styleable#TabWidget_tabStripEnabled
58  * @attr ref android.R.styleable#TabWidget_tabStripLeft
59  * @attr ref android.R.styleable#TabWidget_tabStripRight
60  *
61  * @deprecated new applications should use fragment APIs instead of this class:
62  * Use <a href="{@docRoot}guide/navigation/navigation-swipe-view">TabLayout and ViewPager</a>
63  * instead.
64  */
65 @Deprecated
66 public class TabWidget extends LinearLayout implements OnFocusChangeListener {
67     private final Rect mBounds = new Rect();
68 
69     private OnTabSelectionChanged mSelectionChangedListener;
70 
71     // This value will be set to 0 as soon as the first tab is added to TabHost.
72     @UnsupportedAppUsage(trackingBug = 137825207, maxTargetSdk = Build.VERSION_CODES.Q,
73             publicAlternatives = "Use {@code androidx.viewpager.widget.ViewPager} and "
74                     + "{@code com.google.android.material.tabs.TabLayout} instead.\n"
75                     + "See <a href=\"{@docRoot}guide/navigation/navigation-swipe-view"
76                     + "\">TabLayout and ViewPager</a>")
77     private int mSelectedTab = -1;
78 
79     @Nullable
80     private Drawable mLeftStrip;
81 
82     @Nullable
83     private Drawable mRightStrip;
84 
85     @UnsupportedAppUsage(trackingBug = 137825207, maxTargetSdk = Build.VERSION_CODES.Q,
86             publicAlternatives = "Use {@code androidx.viewpager.widget.ViewPager} and "
87                     + "{@code com.google.android.material.tabs.TabLayout} instead.\n"
88                     + "See <a href=\"{@docRoot}guide/navigation/navigation-swipe-view"
89                     + "\">TabLayout and ViewPager</a>")
90     private boolean mDrawBottomStrips = true;
91     private boolean mStripMoved;
92 
93     // When positive, the widths and heights of tabs will be imposed so that
94     // they fit in parent.
95     private int mImposedTabsHeight = -1;
96     private int[] mImposedTabWidths;
97 
TabWidget(Context context)98     public TabWidget(Context context) {
99         this(context, null);
100     }
101 
TabWidget(Context context, AttributeSet attrs)102     public TabWidget(Context context, AttributeSet attrs) {
103         this(context, attrs, com.android.internal.R.attr.tabWidgetStyle);
104     }
105 
TabWidget(Context context, AttributeSet attrs, int defStyleAttr)106     public TabWidget(Context context, AttributeSet attrs, int defStyleAttr) {
107         this(context, attrs, defStyleAttr, 0);
108     }
109 
TabWidget(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)110     public TabWidget(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
111         super(context, attrs, defStyleAttr, defStyleRes);
112 
113         final TypedArray a = context.obtainStyledAttributes(
114                 attrs, R.styleable.TabWidget, defStyleAttr, defStyleRes);
115         saveAttributeDataForStyleable(context, R.styleable.TabWidget,
116                 attrs, a, defStyleAttr, defStyleRes);
117 
118         mDrawBottomStrips = a.getBoolean(R.styleable.TabWidget_tabStripEnabled, mDrawBottomStrips);
119 
120         // Tests the target SDK version, as set in the Manifest. Could not be
121         // set using styles.xml in a values-v? directory which targets the
122         // current platform SDK version instead.
123         final boolean isTargetSdkDonutOrLower =
124                 context.getApplicationInfo().targetSdkVersion <= Build.VERSION_CODES.DONUT;
125 
126         final boolean hasExplicitLeft = a.hasValueOrEmpty(R.styleable.TabWidget_tabStripLeft);
127         if (hasExplicitLeft) {
128             mLeftStrip = a.getDrawable(R.styleable.TabWidget_tabStripLeft);
129         } else if (isTargetSdkDonutOrLower) {
130             mLeftStrip = context.getDrawable(R.drawable.tab_bottom_left_v4);
131         } else {
132             mLeftStrip = context.getDrawable(R.drawable.tab_bottom_left);
133         }
134 
135         final boolean hasExplicitRight = a.hasValueOrEmpty(R.styleable.TabWidget_tabStripRight);
136         if (hasExplicitRight) {
137             mRightStrip = a.getDrawable(R.styleable.TabWidget_tabStripRight);
138         } else if (isTargetSdkDonutOrLower) {
139             mRightStrip = context.getDrawable(R.drawable.tab_bottom_right_v4);
140         } else {
141             mRightStrip = context.getDrawable(R.drawable.tab_bottom_right);
142         }
143 
144         a.recycle();
145 
146         setChildrenDrawingOrderEnabled(true);
147     }
148 
149     @Override
onSizeChanged(int w, int h, int oldw, int oldh)150     protected void onSizeChanged(int w, int h, int oldw, int oldh) {
151         mStripMoved = true;
152 
153         super.onSizeChanged(w, h, oldw, oldh);
154     }
155 
156     @Override
getChildDrawingOrder(int childCount, int i)157     protected int getChildDrawingOrder(int childCount, int i) {
158         if (mSelectedTab == -1) {
159             return i;
160         } else {
161             // Always draw the selected tab last, so that drop shadows are drawn
162             // in the correct z-order.
163             if (i == childCount - 1) {
164                 return mSelectedTab;
165             } else if (i >= mSelectedTab) {
166                 return i + 1;
167             } else {
168                 return i;
169             }
170         }
171     }
172 
173     @Override
measureChildBeforeLayout(View child, int childIndex, int widthMeasureSpec, int totalWidth, int heightMeasureSpec, int totalHeight)174     void measureChildBeforeLayout(View child, int childIndex, int widthMeasureSpec, int totalWidth,
175             int heightMeasureSpec, int totalHeight) {
176         if (!isMeasureWithLargestChildEnabled() && mImposedTabsHeight >= 0) {
177             widthMeasureSpec = MeasureSpec.makeMeasureSpec(
178                     totalWidth + mImposedTabWidths[childIndex], MeasureSpec.EXACTLY);
179             heightMeasureSpec = MeasureSpec.makeMeasureSpec(mImposedTabsHeight,
180                     MeasureSpec.EXACTLY);
181         }
182 
183         super.measureChildBeforeLayout(child, childIndex,
184                 widthMeasureSpec, totalWidth, heightMeasureSpec, totalHeight);
185     }
186 
187     @Override
measureHorizontal(int widthMeasureSpec, int heightMeasureSpec)188     void measureHorizontal(int widthMeasureSpec, int heightMeasureSpec) {
189         if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED) {
190             super.measureHorizontal(widthMeasureSpec, heightMeasureSpec);
191             return;
192         }
193 
194         // First, measure with no constraint
195         final int width = MeasureSpec.getSize(widthMeasureSpec);
196         final int unspecifiedWidth = MeasureSpec.makeSafeMeasureSpec(width,
197                 MeasureSpec.UNSPECIFIED);
198         mImposedTabsHeight = -1;
199         super.measureHorizontal(unspecifiedWidth, heightMeasureSpec);
200 
201         int extraWidth = getMeasuredWidth() - width;
202         if (extraWidth > 0) {
203             final int count = getChildCount();
204 
205             int childCount = 0;
206             for (int i = 0; i < count; i++) {
207                 final View child = getChildAt(i);
208                 if (child.getVisibility() == GONE) continue;
209                 childCount++;
210             }
211 
212             if (childCount > 0) {
213                 if (mImposedTabWidths == null || mImposedTabWidths.length != count) {
214                     mImposedTabWidths = new int[count];
215                 }
216                 for (int i = 0; i < count; i++) {
217                     final View child = getChildAt(i);
218                     if (child.getVisibility() == GONE) continue;
219                     final int childWidth = child.getMeasuredWidth();
220                     final int delta = extraWidth / childCount;
221                     final int newWidth = Math.max(0, childWidth - delta);
222                     mImposedTabWidths[i] = newWidth;
223                     // Make sure the extra width is evenly distributed, no int division remainder
224                     extraWidth -= childWidth - newWidth; // delta may have been clamped
225                     childCount--;
226                     mImposedTabsHeight = Math.max(mImposedTabsHeight, child.getMeasuredHeight());
227                 }
228             }
229         }
230 
231         // Measure again, this time with imposed tab widths and respecting
232         // initial spec request.
233         super.measureHorizontal(widthMeasureSpec, heightMeasureSpec);
234     }
235 
236     /**
237      * Returns the tab indicator view at the given index.
238      *
239      * @param index the zero-based index of the tab indicator view to return
240      * @return the tab indicator view at the given index
241      */
getChildTabViewAt(int index)242     public View getChildTabViewAt(int index) {
243         return getChildAt(index);
244     }
245 
246     /**
247      * Returns the number of tab indicator views.
248      *
249      * @return the number of tab indicator views
250      */
getTabCount()251     public int getTabCount() {
252         return getChildCount();
253     }
254 
255     /**
256      * Sets the drawable to use as a divider between the tab indicators.
257      *
258      * @param drawable the divider drawable
259      * @attr ref android.R.styleable#TabWidget_divider
260      */
261     @Override
setDividerDrawable(@ullable Drawable drawable)262     public void setDividerDrawable(@Nullable Drawable drawable) {
263         super.setDividerDrawable(drawable);
264     }
265 
266     /**
267      * Sets the drawable to use as a divider between the tab indicators.
268      *
269      * @param resId the resource identifier of the drawable to use as a divider
270      * @attr ref android.R.styleable#TabWidget_divider
271      */
setDividerDrawable(@rawableRes int resId)272     public void setDividerDrawable(@DrawableRes int resId) {
273         setDividerDrawable(mContext.getDrawable(resId));
274     }
275 
276     /**
277      * Sets the drawable to use as the left part of the strip below the tab
278      * indicators.
279      *
280      * @param drawable the left strip drawable
281      * @see #getLeftStripDrawable()
282      * @attr ref android.R.styleable#TabWidget_tabStripLeft
283      */
setLeftStripDrawable(@ullable Drawable drawable)284     public void setLeftStripDrawable(@Nullable Drawable drawable) {
285         mLeftStrip = drawable;
286         requestLayout();
287         invalidate();
288     }
289 
290     /**
291      * Sets the drawable to use as the left part of the strip below the tab
292      * indicators.
293      *
294      * @param resId the resource identifier of the drawable to use as the left
295      *              strip drawable
296      * @see #getLeftStripDrawable()
297      * @attr ref android.R.styleable#TabWidget_tabStripLeft
298      */
setLeftStripDrawable(@rawableRes int resId)299     public void setLeftStripDrawable(@DrawableRes int resId) {
300         setLeftStripDrawable(mContext.getDrawable(resId));
301     }
302 
303     /**
304      * @return the drawable used as the left part of the strip below the tab
305      *         indicators, may be {@code null}
306      * @see #setLeftStripDrawable(int)
307      * @see #setLeftStripDrawable(Drawable)
308      * @attr ref android.R.styleable#TabWidget_tabStripLeft
309      */
310     @Nullable
getLeftStripDrawable()311     public Drawable getLeftStripDrawable() {
312         return mLeftStrip;
313     }
314 
315     /**
316      * Sets the drawable to use as the right part of the strip below the tab
317      * indicators.
318      *
319      * @param drawable the right strip drawable
320      * @see #getRightStripDrawable()
321      * @attr ref android.R.styleable#TabWidget_tabStripRight
322      */
setRightStripDrawable(@ullable Drawable drawable)323     public void setRightStripDrawable(@Nullable Drawable drawable) {
324         mRightStrip = drawable;
325         requestLayout();
326         invalidate();
327     }
328 
329     /**
330      * Sets the drawable to use as the right part of the strip below the tab
331      * indicators.
332      *
333      * @param resId the resource identifier of the drawable to use as the right
334      *              strip drawable
335      * @see #getRightStripDrawable()
336      * @attr ref android.R.styleable#TabWidget_tabStripRight
337      */
setRightStripDrawable(@rawableRes int resId)338     public void setRightStripDrawable(@DrawableRes int resId) {
339         setRightStripDrawable(mContext.getDrawable(resId));
340     }
341 
342     /**
343      * @return the drawable used as the right part of the strip below the tab
344      *         indicators, may be {@code null}
345      * @see #setRightStripDrawable(int)
346      * @see #setRightStripDrawable(Drawable)
347      * @attr ref android.R.styleable#TabWidget_tabStripRight
348      */
349     @Nullable
getRightStripDrawable()350     public Drawable getRightStripDrawable() {
351         return mRightStrip;
352     }
353 
354     /**
355      * Controls whether the bottom strips on the tab indicators are drawn or
356      * not.  The default is to draw them.  If the user specifies a custom
357      * view for the tab indicators, then the TabHost class calls this method
358      * to disable drawing of the bottom strips.
359      * @param stripEnabled true if the bottom strips should be drawn.
360      */
setStripEnabled(boolean stripEnabled)361     public void setStripEnabled(boolean stripEnabled) {
362         mDrawBottomStrips = stripEnabled;
363         invalidate();
364     }
365 
366     /**
367      * Indicates whether the bottom strips on the tab indicators are drawn
368      * or not.
369      */
isStripEnabled()370     public boolean isStripEnabled() {
371         return mDrawBottomStrips;
372     }
373 
374     @Override
childDrawableStateChanged(View child)375     public void childDrawableStateChanged(View child) {
376         if (getTabCount() > 0 && child == getChildTabViewAt(mSelectedTab)) {
377             // To make sure that the bottom strip is redrawn
378             invalidate();
379         }
380         super.childDrawableStateChanged(child);
381     }
382 
383     @Override
dispatchDraw(Canvas canvas)384     public void dispatchDraw(Canvas canvas) {
385         super.dispatchDraw(canvas);
386 
387         // Do nothing if there are no tabs.
388         if (getTabCount() == 0) return;
389 
390         // If the user specified a custom view for the tab indicators, then
391         // do not draw the bottom strips.
392         if (!mDrawBottomStrips) {
393             // Skip drawing the bottom strips.
394             return;
395         }
396 
397         final View selectedChild = getChildTabViewAt(mSelectedTab);
398 
399         final Drawable leftStrip = mLeftStrip;
400         final Drawable rightStrip = mRightStrip;
401 
402         if (leftStrip != null) {
403             leftStrip.setState(selectedChild.getDrawableState());
404         }
405         if (rightStrip != null) {
406             rightStrip.setState(selectedChild.getDrawableState());
407         }
408 
409         if (mStripMoved) {
410             final Rect bounds = mBounds;
411             bounds.left = selectedChild.getLeft();
412             bounds.right = selectedChild.getRight();
413             final int myHeight = getHeight();
414             if (leftStrip != null) {
415                 leftStrip.setBounds(Math.min(0, bounds.left - leftStrip.getIntrinsicWidth()),
416                         myHeight - leftStrip.getIntrinsicHeight(), bounds.left, myHeight);
417             }
418             if (rightStrip != null) {
419                 rightStrip.setBounds(bounds.right, myHeight - rightStrip.getIntrinsicHeight(),
420                         Math.max(getWidth(), bounds.right + rightStrip.getIntrinsicWidth()),
421                         myHeight);
422             }
423             mStripMoved = false;
424         }
425 
426         if (leftStrip != null) {
427             leftStrip.draw(canvas);
428         }
429         if (rightStrip != null) {
430             rightStrip.draw(canvas);
431         }
432     }
433 
434     /**
435      * Sets the current tab.
436      * <p>
437      * This method is used to bring a tab to the front of the Widget,
438      * and is used to post to the rest of the UI that a different tab
439      * has been brought to the foreground.
440      * <p>
441      * Note, this is separate from the traditional "focus" that is
442      * employed from the view logic.
443      * <p>
444      * For instance, if we have a list in a tabbed view, a user may be
445      * navigating up and down the list, moving the UI focus (orange
446      * highlighting) through the list items.  The cursor movement does
447      * not effect the "selected" tab though, because what is being
448      * scrolled through is all on the same tab.  The selected tab only
449      * changes when we navigate between tabs (moving from the list view
450      * to the next tabbed view, in this example).
451      * <p>
452      * To move both the focus AND the selected tab at once, please use
453      * {@link #focusCurrentTab}. Normally, the view logic takes care of
454      * adjusting the focus, so unless you're circumventing the UI,
455      * you'll probably just focus your interest here.
456      *
457      * @param index the index of the tab that you want to indicate as the
458      *              selected tab (tab brought to the front of the widget)
459      * @see #focusCurrentTab
460      */
setCurrentTab(int index)461     public void setCurrentTab(int index) {
462         if (index < 0 || index >= getTabCount() || index == mSelectedTab) {
463             return;
464         }
465 
466         if (mSelectedTab != -1) {
467             getChildTabViewAt(mSelectedTab).setSelected(false);
468         }
469         mSelectedTab = index;
470         getChildTabViewAt(mSelectedTab).setSelected(true);
471         mStripMoved = true;
472     }
473 
474     @Override
getAccessibilityClassName()475     public CharSequence getAccessibilityClassName() {
476         return TabWidget.class.getName();
477     }
478 
479     /** @hide */
480     @Override
onInitializeAccessibilityEventInternal(AccessibilityEvent event)481     public void onInitializeAccessibilityEventInternal(AccessibilityEvent event) {
482         super.onInitializeAccessibilityEventInternal(event);
483         event.setItemCount(getTabCount());
484         event.setCurrentItemIndex(mSelectedTab);
485     }
486 
487     /**
488      * Sets the current tab and focuses the UI on it.
489      * This method makes sure that the focused tab matches the selected
490      * tab, normally at {@link #setCurrentTab}.  Normally this would not
491      * be an issue if we go through the UI, since the UI is responsible
492      * for calling TabWidget.onFocusChanged(), but in the case where we
493      * are selecting the tab programmatically, we'll need to make sure
494      * focus keeps up.
495      *
496      *  @param index The tab that you want focused (highlighted in orange)
497      *  and selected (tab brought to the front of the widget)
498      *
499      *  @see #setCurrentTab
500      */
focusCurrentTab(int index)501     public void focusCurrentTab(int index) {
502         final int oldTab = mSelectedTab;
503 
504         // set the tab
505         setCurrentTab(index);
506 
507         // change the focus if applicable.
508         if (oldTab != index) {
509             getChildTabViewAt(index).requestFocus();
510         }
511     }
512 
513     @Override
setEnabled(boolean enabled)514     public void setEnabled(boolean enabled) {
515         super.setEnabled(enabled);
516 
517         final int count = getTabCount();
518         for (int i = 0; i < count; i++) {
519             final View child = getChildTabViewAt(i);
520             child.setEnabled(enabled);
521         }
522     }
523 
524     @FlaggedApi(FLAG_ENABLE_ARROW_ICON_ON_HOVER_WHEN_CLICKABLE)
525     @Override
addView(View child)526     public void addView(View child) {
527         if (child.getLayoutParams() == null) {
528             final LinearLayout.LayoutParams lp = new LayoutParams(
529                     0, ViewGroup.LayoutParams.MATCH_PARENT, 1.0f);
530             lp.setMargins(0, 0, 0, 0);
531             child.setLayoutParams(lp);
532         }
533 
534         // Ensure you can navigate to the tab with the keyboard, and you can touch it
535         child.setFocusable(true);
536         child.setClickable(true);
537 
538         // By default the pointer icon is an arrow. More specifically, when the pointer icon is set
539         // to null, it will be an arrow. Therefore, we don't need to change the icon when
540         // enableArrowIconOnHoverWhenClickable() and the pointer icon is a null. We only need to do
541         // that when we want the hand icon for hover.
542         if (!enableArrowIconOnHoverWhenClickable() && child.getPointerIcon() == null) {
543             child.setPointerIcon(PointerIcon.getSystemIcon(getContext(), PointerIcon.TYPE_HAND));
544         }
545 
546         super.addView(child);
547 
548         // TODO: detect this via geometry with a tabwidget listener rather
549         // than potentially interfere with the view's listener
550         child.setOnClickListener(new TabClickListener(getTabCount() - 1));
551     }
552 
553     @Override
removeAllViews()554     public void removeAllViews() {
555         super.removeAllViews();
556         mSelectedTab = -1;
557     }
558 
559     @Override
onResolvePointerIcon(MotionEvent event, int pointerIndex)560     public PointerIcon onResolvePointerIcon(MotionEvent event, int pointerIndex) {
561         if (!isEnabled()) {
562             return null;
563         }
564         return super.onResolvePointerIcon(event, pointerIndex);
565     }
566 
567     /**
568      * Provides a way for {@link TabHost} to be notified that the user clicked
569      * on a tab indicator.
570      */
571     @UnsupportedAppUsage(trackingBug = 137825207, maxTargetSdk = Build.VERSION_CODES.Q,
572             publicAlternatives = "Use {@code androidx.viewpager.widget.ViewPager} and "
573                     + "{@code com.google.android.material.tabs.TabLayout} instead.\n"
574                     + "See <a href=\"{@docRoot}guide/navigation/navigation-swipe-view"
575                     + "\">TabLayout and ViewPager</a>")
setTabSelectionListener(OnTabSelectionChanged listener)576     void setTabSelectionListener(OnTabSelectionChanged listener) {
577         mSelectionChangedListener = listener;
578     }
579 
580     @Override
onFocusChange(View v, boolean hasFocus)581     public void onFocusChange(View v, boolean hasFocus) {
582         // No-op. Tab selection is separate from keyboard focus.
583     }
584 
585     // registered with each tab indicator so we can notify tab host
586     private class TabClickListener implements OnClickListener {
587         private final int mTabIndex;
588 
TabClickListener(int tabIndex)589         private TabClickListener(int tabIndex) {
590             mTabIndex = tabIndex;
591         }
592 
onClick(View v)593         public void onClick(View v) {
594             mSelectionChangedListener.onTabSelectionChanged(mTabIndex, true);
595         }
596     }
597 
598     /**
599      * Lets {@link TabHost} know that the user clicked on a tab indicator.
600      */
601     interface OnTabSelectionChanged {
602         /**
603          * Informs the TabHost which tab was selected. It also indicates
604          * if the tab was clicked/pressed or just focused into.
605          *
606          * @param tabIndex index of the tab that was selected
607          * @param clicked whether the selection changed due to a touch/click or
608          *                due to focus entering the tab through navigation.
609          *                {@code true} if it was due to a press/click and
610          *                {@code false} otherwise.
611          */
onTabSelectionChanged(int tabIndex, boolean clicked)612         void onTabSelectionChanged(int tabIndex, boolean clicked);
613     }
614 }
615