/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.android.material.tabs; import com.google.android.material.R; import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; import static androidx.viewpager.widget.ViewPager.SCROLL_STATE_DRAGGING; import static androidx.viewpager.widget.ViewPager.SCROLL_STATE_IDLE; import static androidx.viewpager.widget.ViewPager.SCROLL_STATE_SETTLING; import static com.google.android.material.animation.AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR; import static com.google.android.material.theme.overlay.MaterialThemeOverlay.wrap; import android.animation.TimeInterpolator; import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.Resources; import android.content.res.TypedArray; import android.database.DataSetObserver; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; import android.graphics.drawable.LayerDrawable; import android.graphics.drawable.RippleDrawable; import android.os.Build; import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.widget.TooltipCompat; import android.text.Layout; import android.text.TextUtils; import android.util.AttributeSet; import android.util.Log; import android.util.TypedValue; import android.view.Gravity; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.SoundEffectConstants; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityNodeInfo; import android.widget.FrameLayout; import android.widget.HorizontalScrollView; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import androidx.annotation.BoolRes; import androidx.annotation.ColorInt; import androidx.annotation.ColorRes; import androidx.annotation.Dimension; import androidx.annotation.DrawableRes; import androidx.annotation.IntDef; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.RestrictTo; import androidx.annotation.StringRes; import androidx.core.graphics.drawable.DrawableCompat; import androidx.core.util.Pools; import androidx.core.view.GravityCompat; import androidx.core.view.MarginLayoutParamsCompat; import androidx.core.view.PointerIconCompat; import androidx.core.view.ViewCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat; import androidx.core.widget.TextViewCompat; import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; import com.google.android.material.badge.BadgeDrawable; import com.google.android.material.badge.BadgeUtils; import com.google.android.material.drawable.DrawableUtils; import com.google.android.material.internal.ThemeEnforcement; import com.google.android.material.internal.ViewUtils; import com.google.android.material.motion.MotionUtils; import com.google.android.material.resources.MaterialResources; import com.google.android.material.ripple.RippleUtils; import com.google.android.material.shape.MaterialShapeDrawable; import com.google.android.material.shape.MaterialShapeUtils; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Iterator; /** * TabLayout provides a horizontal layout to display tabs. * *
Population of the tabs to display is done through {@link Tab} instances. You create tabs via * {@link #newTab()}. From there you can change the tab's label or icon via {@link Tab#setText(int)} * and {@link Tab#setIcon(int)} respectively. To display the tab, you need to add it to the layout * via one of the {@link #addTab(Tab)} methods. For example: * *
* TabLayout tabLayout = ...;
* tabLayout.addTab(tabLayout.newTab().setText("Tab 1"));
* tabLayout.addTab(tabLayout.newTab().setText("Tab 2"));
* tabLayout.addTab(tabLayout.newTab().setText("Tab 3"));
*
*
* You should add a listener via {@link #addOnTabSelectedListener(OnTabSelectedListener)} to be
* notified when any tab's selection state has been changed.
*
* You can also add items to TabLayout in your layout through the use of {@link TabItem}. An * example usage is like so: * *
* <com.google.android.material.tabs.TabLayout * android:layout_height="wrap_content" * android:layout_width="match_parent"> * * <com.google.android.material.tabs.TabItem * android:text="@string/tab_text"/> * * <com.google.android.material.tabs.TabItem * android:icon="@drawable/ic_android"/> * * </com.google.android.material.tabs.TabLayout> ** *
If you're using a {@link androidx.viewpager.widget.ViewPager} together with this layout, you * can call {@link #setupWithViewPager(ViewPager)} to link the two together. This layout will be * automatically populated from the {@link PagerAdapter}'s page titles. * *
This view also supports being used as part of a ViewPager's decor, and can be added directly * to the ViewPager in a layout resource file like so: * *
* <androidx.viewpager.widget.ViewPager * android:layout_width="match_parent" * android:layout_height="match_parent"> * * <com.google.android.material.tabs.TabLayout * android:layout_width="match_parent" * android:layout_height="wrap_content" * android:layout_gravity="top" /> * * </androidx.viewpager.widget.ViewPager> ** *
For more information, see the component
* developer guidance and design
* guidelines.
*
* @see Tabs
* @attr ref com.google.android.material.R.styleable#TabLayout_tabPadding
* @attr ref com.google.android.material.R.styleable#TabLayout_tabPaddingStart
* @attr ref com.google.android.material.R.styleable#TabLayout_tabPaddingTop
* @attr ref com.google.android.material.R.styleable#TabLayout_tabPaddingEnd
* @attr ref com.google.android.material.R.styleable#TabLayout_tabPaddingBottom
* @attr ref com.google.android.material.R.styleable#TabLayout_tabContentStart
* @attr ref com.google.android.material.R.styleable#TabLayout_tabBackground
* @attr ref com.google.android.material.R.styleable#TabLayout_tabMinWidth
* @attr ref com.google.android.material.R.styleable#TabLayout_tabMaxWidth
* @attr ref com.google.android.material.R.styleable#TabLayout_tabTextAppearance
*/
@ViewPager.DecorView
public class TabLayout extends HorizontalScrollView {
private static final int DEF_STYLE_RES = R.style.Widget_Design_TabLayout;
@Dimension(unit = Dimension.DP)
private static final int DEFAULT_HEIGHT_WITH_TEXT_ICON = 72;
@Dimension(unit = Dimension.DP)
static final int DEFAULT_GAP_TEXT_ICON = 8;
@Dimension(unit = Dimension.DP)
private static final int DEFAULT_HEIGHT = 48;
@Dimension(unit = Dimension.DP)
private static final int TAB_MIN_WIDTH_MARGIN = 56;
@Dimension(unit = Dimension.DP)
static final int FIXED_WRAP_GUTTER_MIN = 16;
private static final int INVALID_WIDTH = -1;
private static final int ANIMATION_DURATION = 300;
private static final int SELECTED_INDICATOR_HEIGHT_DEFAULT = -1;
private static final Pools.Pool The left and right side of the selection indicator translate in step over the duration of
* the animation. The only exception to this is when the indicator needs to change size to fit the
* width of its new destination tab's label.
*
* @see #setTabIndicatorAnimationMode(int)
* @see #getTabIndicatorAnimationMode()
* @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorAnimationMode
*/
public static final int INDICATOR_ANIMATION_MODE_LINEAR = 0;
/**
* Indicator animation mode used to translate the selected tab indicator by growing and then
* shrinking the indicator, making the indicator look like it is stretching while translating
* between destinations.
*
* The left and right side of the selection indicator translate out of step - with the right
* decelerating and the left accelerating (when moving right). This difference in velocity between
* the sides of the indicator, over the duration of the animation, make the indicator look like it
* grows and then shrinks back down to fit it's new destination's width.
*
* @see #setTabIndicatorAnimationMode(int)
* @see #getTabIndicatorAnimationMode()
* @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorAnimationMode
*/
public static final int INDICATOR_ANIMATION_MODE_ELASTIC = 1;
/**
* Indicator animation mode used to switch the selected tab indicator from one tab to another
* by sequentially fading it out from the current destination and in at its new destination.
*
* @see #setTabIndicatorAnimationMode(int)
* @see #getTabIndicatorAnimationMode()
* @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorAnimationMode
*/
public static final int INDICATOR_ANIMATION_MODE_FADE = 2;
/** @hide */
@RestrictTo(LIBRARY_GROUP)
@IntDef(value = {
INDICATOR_ANIMATION_MODE_LINEAR,
INDICATOR_ANIMATION_MODE_ELASTIC,
INDICATOR_ANIMATION_MODE_FADE
})
@Retention(RetentionPolicy.SOURCE)
public @interface TabIndicatorAnimationMode {}
/** Callback interface invoked when a tab's selection state changes. */
public interface OnTabSelectedListener extends BaseOnTabSelectedListener If the tab indicator color is not {@code Color.TRANSPARENT}, the indicator will be wrapped
* and tinted right before it is drawn by {@link SlidingTabIndicator#draw(Canvas)}. If you'd like
* the inherent color or the tinted color of a custom drawable to be used, make sure this color is
* set to {@code Color.TRANSPARENT} to avoid your color/tint being overridden.
*
* @param color color to use for the indicator
* @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorColor
*/
public void setSelectedTabIndicatorColor(@ColorInt int color) {
this.tabSelectedIndicatorColor = color;
DrawableUtils.setTint(tabSelectedIndicator, tabSelectedIndicatorColor);
updateTabViews(false);
}
/**
* Sets the tab indicator's height for the currently selected tab.
*
* @deprecated If possible, set the intrinsic height directly on a custom indicator drawable
* passed to {@link #setSelectedTabIndicator(Drawable)}.
* @param height height to use for the indicator in pixels
* @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorHeight
*/
@Deprecated
public void setSelectedTabIndicatorHeight(int height) {
tabIndicatorHeight = height;
slidingTabIndicator.setSelectedIndicatorHeight(height);
}
/**
* Set the scroll position of the {@link TabLayout}.
*
* @param position Position of the tab to scroll.
* @param positionOffset Value from [0, 1) indicating the offset from {@code position}.
* @param updateSelectedTabView Whether to draw the tab at the specified position + positionOffset
* as selected.
* Note that calling the method with {@code updateSelectedTabView = true}
* does not select a tab at the specified position, but only draws it
* as selected. This can be useful for when the TabLayout behavior needs to be linked to
* another view, such as {@link androidx.viewpager.widget.ViewPager}.
* @see #setScrollPosition(int, float, boolean, boolean)
*/
public void setScrollPosition(int position, float positionOffset, boolean updateSelectedTabView) {
setScrollPosition(position, positionOffset, updateSelectedTabView, true);
}
/**
* Set the scroll position of the {@link TabLayout}.
*
* @param position Position of the tab to scroll.
* @param positionOffset Value from [0, 1) indicating the offset from {@code position}.
* @param updateSelectedTabView Whether to draw the tab at the specified position + positionOffset
* as selected.
* Note that calling the method with {@code updateSelectedTabView = true}
* does not select a tab at the specified position, but only draws it
* as selected. This can be useful for when the TabLayout behavior needs to be linked to
* another view, such as {@link androidx.viewpager.widget.ViewPager}.
* @param updateIndicatorPosition Whether to set the indicator to the specified position and
* offset.
* Note that calling the method with {@code updateIndicatorPosition = true}
* does not select a tab at the specified position, but only updates the indicator
* position. This can be useful for when the TabLayout behavior needs to be linked to
* another view, such as {@link androidx.viewpager.widget.ViewPager}.
* @see #setScrollPosition(int, float, boolean)
*/
public void setScrollPosition(
int position,
float positionOffset,
boolean updateSelectedTabView,
boolean updateIndicatorPosition) {
setScrollPosition(
position,
positionOffset,
updateSelectedTabView,
updateIndicatorPosition,
/* alwaysScroll= */ true);
}
void setScrollPosition(
int position,
float positionOffset,
boolean updateSelectedTabView,
boolean updateIndicatorPosition,
boolean alwaysScroll) {
final int roundedPosition = Math.round(position + positionOffset);
if (roundedPosition < 0 || roundedPosition >= slidingTabIndicator.getChildCount()) {
return;
}
// Set the indicator position, if enabled
if (updateIndicatorPosition) {
slidingTabIndicator.setIndicatorPositionFromTabPosition(position, positionOffset);
}
// Now update the scroll position, canceling any running animation
if (scrollAnimator != null && scrollAnimator.isRunning()) {
scrollAnimator.cancel();
}
int scrollXForPosition = calculateScrollXForTab(position, positionOffset);
int scrollX = getScrollX();
// If the position is smaller than the selected tab position, the position is getting larger
// to reach the selected tab position so scrollX is increasing.
// We only want to update the scroll position if the new scroll position is greater than
// the current scroll position.
// Conversely if the position is greater than the selected tab position, the position is
// getting smaller to reach the selected tab position so scrollX is decreasing.
// We only update the scroll position if the new scroll position is less than the current
// scroll position.
// Lastly if the position is equal to the selected position, we want to set the scroll
// position which also updates the selected tab view and the indicator.
boolean toMove =
(position < getSelectedTabPosition() && scrollXForPosition >= scrollX)
|| (position > getSelectedTabPosition() && scrollXForPosition <= scrollX)
|| (position == getSelectedTabPosition());
// If the layout direction is RTL, the scrollXForPosition and scrollX comparisons are
// reversed since scrollX values remain the same in RTL but tab positions go RTL.
if (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL) {
toMove =
(position < getSelectedTabPosition() && scrollXForPosition <= scrollX)
|| (position > getSelectedTabPosition()
&& scrollXForPosition >= scrollX)
|| (position == getSelectedTabPosition());
}
// We want to scroll if alwaysScroll is true, the viewpager is being dragged, or if we should
// scroll by the rules above.
if (toMove || viewPagerScrollState == SCROLL_STATE_DRAGGING || alwaysScroll) {
scrollTo(position < 0 ? 0 : scrollXForPosition, 0);
}
// Update the 'selected state' view as we scroll, if enabled
if (updateSelectedTabView) {
setSelectedTabView(roundedPosition);
}
}
/**
* Add a tab to this layout. The tab will be added at the end of the list. If this is the first
* tab to be added it will become the selected tab.
*
* @param tab Tab to add
*/
public void addTab(@NonNull Tab tab) {
addTab(tab, tabs.isEmpty());
}
/**
* Add a tab to this layout. The tab will be inserted at Components that add a listener should take care to remove it when finished via {@link
* #removeOnTabSelectedListener(OnTabSelectedListener)}.
*
* @param listener listener to add
*/
public void addOnTabSelectedListener(@NonNull OnTabSelectedListener listener) {
addOnTabSelectedListener((BaseOnTabSelectedListener) listener);
}
/**
* Add a {@link TabLayout.BaseOnTabSelectedListener} that will be invoked when tab selection
* changes.
*
* Components that add a listener should take care to remove it when finished via {@link
* #removeOnTabSelectedListener(BaseOnTabSelectedListener)}.
*
* @param listener listener to add
* @deprecated use {@link #addOnTabSelectedListener(OnTabSelectedListener)}
*/
@Deprecated
public void addOnTabSelectedListener(@Nullable BaseOnTabSelectedListener listener) {
if (!selectedListeners.contains(listener)) {
selectedListeners.add(listener);
}
}
/**
* Remove the given {@link TabLayout.OnTabSelectedListener} that was previously added via {@link
* #addOnTabSelectedListener(OnTabSelectedListener)}.
*
* @param listener listener to remove
*/
public void removeOnTabSelectedListener(@NonNull OnTabSelectedListener listener) {
removeOnTabSelectedListener((BaseOnTabSelectedListener) listener);
}
/**
* Remove the given {@link TabLayout.BaseOnTabSelectedListener} that was previously added via
* {@link #addOnTabSelectedListener(BaseOnTabSelectedListener)}.
*
* @param listener listener to remove
* @deprecated use {@link #removeOnTabSelectedListener(OnTabSelectedListener)}
*/
@Deprecated
public void removeOnTabSelectedListener(@Nullable BaseOnTabSelectedListener listener) {
selectedListeners.remove(listener);
}
/** Remove all previously added {@link TabLayout.OnTabSelectedListener}s. */
public void clearOnTabSelectedListeners() {
selectedListeners.clear();
}
/**
* Create and return a new {@link Tab}. You need to manually add this using {@link #addTab(Tab)}
* or a related method.
*
* @return A new Tab
* @see #addTab(Tab)
*/
@NonNull
public Tab newTab() {
Tab tab = createTabFromPool();
tab.parent = this;
tab.view = createTabView(tab);
if (tab.id != NO_ID) {
tab.view.setId(tab.id);
}
return tab;
}
// TODO(b/76413401): remove this method and just create the final field after the widget migration
protected Tab createTabFromPool() {
Tab tab = tabPool.acquire();
if (tab == null) {
tab = new Tab();
}
return tab;
}
// TODO(b/76413401): remove this method and just create the final field after the widget migration
protected boolean releaseFromTabPool(Tab tab) {
return tabPool.release(tab);
}
/**
* Returns the number of tabs currently registered with the tab layout.
*
* @return Tab count
*/
public int getTabCount() {
return tabs.size();
}
/** Returns the tab at the specified index. */
@Nullable
public Tab getTabAt(int index) {
return (index < 0 || index >= getTabCount()) ? null : tabs.get(index);
}
/**
* Returns the position of the current selected tab.
*
* @return selected tab position, or {@code -1} if there isn't a selected tab.
*/
public int getSelectedTabPosition() {
return selectedTab != null ? selectedTab.getPosition() : -1;
}
/**
* Remove a tab from the layout. If the removed tab was selected it will be deselected and another
* tab will be selected if present.
*
* @param tab The tab to remove
*/
public void removeTab(@NonNull Tab tab) {
if (tab.parent != this) {
throw new IllegalArgumentException("Tab does not belong to this TabLayout.");
}
removeTabAt(tab.getPosition());
}
/**
* Remove a tab from the layout. If the removed tab was selected it will be deselected and another
* tab will be selected if present.
*
* @param position Position of the tab to remove
*/
public void removeTabAt(int position) {
final int selectedTabPosition = selectedTab != null ? selectedTab.getPosition() : 0;
removeTabViewAt(position);
final Tab removedTab = tabs.remove(position);
if (removedTab != null) {
removedTab.reset();
releaseFromTabPool(removedTab);
}
final int newTabCount = tabs.size();
int newIndicatorPosition = -1;
for (int i = position; i < newTabCount; i++) {
// If the current tab position is the indicator position, mark its new position as the new
// indicator position.
if (tabs.get(i).getPosition() == indicatorPosition) {
newIndicatorPosition = i;
}
tabs.get(i).setPosition(i);
}
// Update the indicator position to the correct selected tab after refreshing tab positions.
indicatorPosition = newIndicatorPosition;
if (selectedTabPosition == position) {
selectTab(tabs.isEmpty() ? null : tabs.get(Math.max(0, position - 1)));
}
}
/** Remove all tabs from the tab layout and deselect the current tab. */
public void removeAllTabs() {
// Remove all the views
for (int i = slidingTabIndicator.getChildCount() - 1; i >= 0; i--) {
removeTabViewAt(i);
}
for (final Iterator Defaults to {@link #INDICATOR_ANIMATION_MODE_LINEAR}. Changing this is useful as a stylistic
* choice.
*
* @param tabIndicatorAnimationMode one of {@link #INDICATOR_ANIMATION_MODE_LINEAR} or {@link
* #INDICATOR_ANIMATION_MODE_ELASTIC}
* @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorAnimationMode
* @see #getTabIndicatorAnimationMode()
*/
public void setTabIndicatorAnimationMode(
@TabIndicatorAnimationMode int tabIndicatorAnimationMode) {
this.tabIndicatorAnimationMode = tabIndicatorAnimationMode;
switch (tabIndicatorAnimationMode) {
case INDICATOR_ANIMATION_MODE_LINEAR:
this.tabIndicatorInterpolator = new TabIndicatorInterpolator();
break;
case INDICATOR_ANIMATION_MODE_ELASTIC:
this.tabIndicatorInterpolator = new ElasticTabIndicatorInterpolator();
break;
case INDICATOR_ANIMATION_MODE_FADE:
this.tabIndicatorInterpolator = new FadeTabIndicatorInterpolator();
break;
default:
throw new IllegalArgumentException(
tabIndicatorAnimationMode + " is not a valid TabIndicatorAnimationMode");
}
}
/**
* Get the current indicator animation mode used to animate the selection indicator between
* destinations.
*
* @return one of {@link #INDICATOR_ANIMATION_MODE_LINEAR} or {@link
* #INDICATOR_ANIMATION_MODE_ELASTIC}
* @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorAnimationMode
* @see #setTabIndicatorAnimationMode(int)
*/
@TabIndicatorAnimationMode
public int getTabIndicatorAnimationMode() {
return tabIndicatorAnimationMode;
}
/**
* Enable or disable option to fit the tab selection indicator to the full width of the tab item
* rather than to the tab item's content.
*
* Defaults to true. If set to false and the tab item has a text label, the selection indicator
* width will be set to the width of the text label. If the tab item has no text label, but does
* have an icon, the selection indicator width will be set to the icon. If the tab item has
* neither of these, or if the calculated width is less than a minimum width value, the selection
* indicator width will be set to the minimum width value.
*
* @param tabIndicatorFullWidth Whether or not to fit selection indicator width to full width of
* the tab item
* @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorFullWidth
* @see #isTabIndicatorFullWidth()
*/
public void setTabIndicatorFullWidth(boolean tabIndicatorFullWidth) {
this.tabIndicatorFullWidth = tabIndicatorFullWidth;
slidingTabIndicator.jumpIndicatorToSelectedPosition();
ViewCompat.postInvalidateOnAnimation(slidingTabIndicator);
}
/**
* Get whether or not selection indicator width is fit to full width of the tab item, or fit to
* the tab item's content.
*
* @return whether or not selection indicator width is fit to the full width of the tab item
* @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorFullWidth
* @see #setTabIndicatorFullWidth(boolean)
*/
public boolean isTabIndicatorFullWidth() {
return tabIndicatorFullWidth;
}
/**
* Set whether tab labels will be displayed inline with tab icons, or if they will be displayed
* underneath tab icons.
*
* @see #isInlineLabel()
* @attr ref com.google.android.material.R.styleable#TabLayout_tabInlineLabel
*/
public void setInlineLabel(boolean inline) {
if (inlineLabel != inline) {
inlineLabel = inline;
for (int i = 0; i < slidingTabIndicator.getChildCount(); i++) {
View child = slidingTabIndicator.getChildAt(i);
if (child instanceof TabView) {
((TabView) child).updateOrientation();
}
}
applyModeAndGravity();
}
}
/**
* Set whether tab labels will be displayed inline with tab icons, or if they will be displayed
* underneath tab icons.
*
* @param inlineResourceId Resource ID for boolean inline flag
* @see #isInlineLabel()
* @attr ref com.google.android.material.R.styleable#TabLayout_tabInlineLabel
*/
public void setInlineLabelResource(@BoolRes int inlineResourceId) {
setInlineLabel(getResources().getBoolean(inlineResourceId));
}
/**
* Returns whether tab labels will be displayed inline with tab icons, or if they will be
* displayed underneath tab icons.
*
* @see #setInlineLabel(boolean)
* @attr ref com.google.android.material.R.styleable#TabLayout_tabInlineLabel
*/
public boolean isInlineLabel() {
return inlineLabel;
}
/**
* Set whether this {@link TabLayout} will have an unbounded ripple effect or if ripple will be
* bound to the tab item size.
*
* Defaults to false.
*
* @see #hasUnboundedRipple()
* @attr ref com.google.android.material.R.styleable#TabLayout_tabUnboundedRipple
*/
public void setUnboundedRipple(boolean unboundedRipple) {
if (this.unboundedRipple != unboundedRipple) {
this.unboundedRipple = unboundedRipple;
for (int i = 0; i < slidingTabIndicator.getChildCount(); i++) {
View child = slidingTabIndicator.getChildAt(i);
if (child instanceof TabView) {
((TabView) child).updateBackgroundDrawable(getContext());
}
}
}
}
/**
* Set whether this {@link TabLayout} will have an unbounded ripple effect or if ripple will be
* bound to the tab item size. Defaults to false.
*
* @param unboundedRippleResourceId Resource ID for boolean unbounded ripple value
* @see #hasUnboundedRipple()
* @attr ref com.google.android.material.R.styleable#TabLayout_tabUnboundedRipple
*/
public void setUnboundedRippleResource(@BoolRes int unboundedRippleResourceId) {
setUnboundedRipple(getResources().getBoolean(unboundedRippleResourceId));
}
/**
* Returns whether this {@link TabLayout} has an unbounded ripple effect, or if ripple is bound to
* the tab item size.
*
* @see #setUnboundedRipple(boolean)
* @attr ref com.google.android.material.R.styleable#TabLayout_tabUnboundedRipple
*/
public boolean hasUnboundedRipple() {
return unboundedRipple;
}
/**
* Sets the text colors for the different states (normal, selected) used for the tabs.
*
* @see #getTabTextColors()
*/
public void setTabTextColors(@Nullable ColorStateList textColor) {
if (tabTextColors != textColor) {
tabTextColors = textColor;
updateAllTabs();
}
}
/** Gets the text colors for the different states (normal, selected) used for the tabs. */
@Nullable
public ColorStateList getTabTextColors() {
return tabTextColors;
}
/**
* Sets the text colors for the different states (normal, selected) used for the tabs.
*
* @attr ref com.google.android.material.R.styleable#TabLayout_tabTextColor
* @attr ref com.google.android.material.R.styleable#TabLayout_tabSelectedTextColor
*/
public void setTabTextColors(int normalColor, int selectedColor) {
setTabTextColors(createColorStateList(normalColor, selectedColor));
}
/**
* Sets the icon tint for the different states (normal, selected) used for the tabs.
*
* @see #getTabIconTint()
*/
public void setTabIconTint(@Nullable ColorStateList iconTint) {
if (tabIconTint != iconTint) {
tabIconTint = iconTint;
updateAllTabs();
}
}
/**
* Sets the icon tint resource for the different states (normal, selected) used for the tabs.
*
* @param iconTintResourceId A color resource to use as icon tint.
* @see #getTabIconTint()
*/
public void setTabIconTintResource(@ColorRes int iconTintResourceId) {
setTabIconTint(AppCompatResources.getColorStateList(getContext(), iconTintResourceId));
}
/** Gets the icon tint for the different states (normal, selected) used for the tabs. */
@Nullable
public ColorStateList getTabIconTint() {
return tabIconTint;
}
/**
* Returns the ripple color for this TabLayout.
*
* @return the color (or ColorStateList) used for the ripple
* @see #setTabRippleColor(ColorStateList)
*/
@Nullable
public ColorStateList getTabRippleColor() {
return tabRippleColorStateList;
}
/**
* Sets the ripple color for this TabLayout.
*
* When running on devices with KitKat or below, we draw this color as a filled overlay rather
* than a ripple.
*
* @param color color (or ColorStateList) to use for the ripple
* @attr ref com.google.android.material.R.styleable#TabLayout_tabRippleColor
* @see #getTabRippleColor()
*/
public void setTabRippleColor(@Nullable ColorStateList color) {
if (tabRippleColorStateList != color) {
tabRippleColorStateList = color;
for (int i = 0; i < slidingTabIndicator.getChildCount(); i++) {
View child = slidingTabIndicator.getChildAt(i);
if (child instanceof TabView) {
((TabView) child).updateBackgroundDrawable(getContext());
}
}
}
}
/**
* Sets the ripple color resource for this TabLayout.
*
* When running on devices with KitKat or below, we draw this color as a filled overlay rather
* than a ripple.
*
* @param tabRippleColorResourceId A color resource to use as ripple color.
* @see #getTabRippleColor()
*/
public void setTabRippleColorResource(@ColorRes int tabRippleColorResourceId) {
setTabRippleColor(AppCompatResources.getColorStateList(getContext(), tabRippleColorResourceId));
}
/**
* Returns the selection indicator drawable for this TabLayout.
*
* @return The drawable used as the tab selection indicator, if set.
* @see #setSelectedTabIndicator(Drawable)
* @see #setSelectedTabIndicator(int)
*/
@NonNull
public Drawable getTabSelectedIndicator() {
return tabSelectedIndicator;
}
/**
* Sets the selection indicator for this TabLayout. By default, this is a line along the bottom of
* the tab. If {@code tabIndicatorColor} is specified via the TabLayout's style or via {@link
* #setSelectedTabIndicatorColor(int)} the selection indicator will be tinted that color.
* Otherwise, it will use the colors specified in the drawable.
*
* Setting the indicator drawable to null will cause {@link TabLayout} to use the default,
* {@link GradientDrawable} line indicator.
*
* @param tabSelectedIndicator A drawable to use as the selected tab indicator.
* @see #setSelectedTabIndicatorColor(int)
* @see #setSelectedTabIndicator(int)
*/
public void setSelectedTabIndicator(@Nullable Drawable tabSelectedIndicator) {
if (tabSelectedIndicator == null) {
tabSelectedIndicator = new GradientDrawable();
}
this.tabSelectedIndicator = DrawableCompat.wrap(tabSelectedIndicator).mutate();
DrawableUtils.setTint(this.tabSelectedIndicator, tabSelectedIndicatorColor);
int indicatorHeight =
tabIndicatorHeight == SELECTED_INDICATOR_HEIGHT_DEFAULT
? this.tabSelectedIndicator.getIntrinsicHeight()
: tabIndicatorHeight;
slidingTabIndicator.setSelectedIndicatorHeight(indicatorHeight);
}
/**
* Sets the drawable resource to use as the selection indicator for this TabLayout. By default,
* this is a line along the bottom of the tab. If {@code tabIndicatorColor} is specified via the
* TabLayout's style or via {@link #setSelectedTabIndicatorColor(int)} the selection indicator
* will be tinted that color. Otherwise, it will use the colors specified in the drawable.
*
* @param tabSelectedIndicatorResourceId A drawable resource to use as the selected tab indicator.
* @see #setSelectedTabIndicatorColor(int)
* @see #setSelectedTabIndicator(Drawable)
*/
public void setSelectedTabIndicator(@DrawableRes int tabSelectedIndicatorResourceId) {
if (tabSelectedIndicatorResourceId != 0) {
setSelectedTabIndicator(
AppCompatResources.getDrawable(getContext(), tabSelectedIndicatorResourceId));
} else {
setSelectedTabIndicator(null);
}
}
/**
* The one-stop shop for setting up this {@link TabLayout} with a {@link ViewPager}.
*
* This is the same as calling {@link #setupWithViewPager(ViewPager, boolean)} with
* auto-refresh enabled.
*
* @param viewPager the ViewPager to link to, or {@code null} to clear any previous link
*/
public void setupWithViewPager(@Nullable ViewPager viewPager) {
setupWithViewPager(viewPager, true);
}
/**
* The one-stop shop for setting up this {@link TabLayout} with a {@link ViewPager}.
*
* This method will link the given ViewPager and this TabLayout together so that changes in one
* are automatically reflected in the other. This includes scroll state changes and clicks. The
* tabs displayed in this layout will be populated from the ViewPager adapter's page titles.
*
* If {@code autoRefresh} is {@code true}, any changes in the {@link PagerAdapter} will trigger
* this layout to re-populate itself from the adapter's titles.
*
* If the given ViewPager is non-null, it needs to already have a {@link PagerAdapter} set.
*
* @param viewPager the ViewPager to link to, or {@code null} to clear any previous link
* @param autoRefresh whether this layout should refresh its contents if the given ViewPager's
* content changes
*/
public void setupWithViewPager(@Nullable final ViewPager viewPager, boolean autoRefresh) {
setupWithViewPager(viewPager, autoRefresh, false);
}
private void setupWithViewPager(
@Nullable final ViewPager viewPager, boolean autoRefresh, boolean implicitSetup) {
if (this.viewPager != null) {
// If we've already been setup with a ViewPager, remove us from it
if (pageChangeListener != null) {
this.viewPager.removeOnPageChangeListener(pageChangeListener);
}
if (adapterChangeListener != null) {
this.viewPager.removeOnAdapterChangeListener(adapterChangeListener);
}
}
if (currentVpSelectedListener != null) {
// If we already have a tab selected listener for the ViewPager, remove it
removeOnTabSelectedListener(currentVpSelectedListener);
currentVpSelectedListener = null;
}
if (viewPager != null) {
this.viewPager = viewPager;
// Add our custom OnPageChangeListener to the ViewPager
if (pageChangeListener == null) {
pageChangeListener = new TabLayoutOnPageChangeListener(this);
}
pageChangeListener.reset();
viewPager.addOnPageChangeListener(pageChangeListener);
// Now we'll add a tab selected listener to set ViewPager's current item
currentVpSelectedListener = new ViewPagerOnTabSelectedListener(viewPager);
addOnTabSelectedListener(currentVpSelectedListener);
final PagerAdapter adapter = viewPager.getAdapter();
if (adapter != null) {
// Now we'll populate ourselves from the pager adapter, adding an observer if
// autoRefresh is enabled
setPagerAdapter(adapter, autoRefresh);
}
// Add a listener so that we're notified of any adapter changes
if (adapterChangeListener == null) {
adapterChangeListener = new AdapterChangeListener();
}
adapterChangeListener.setAutoRefresh(autoRefresh);
viewPager.addOnAdapterChangeListener(adapterChangeListener);
// Now update the scroll position to match the ViewPager's current item
setScrollPosition(viewPager.getCurrentItem(), 0f, true);
} else {
// We've been given a null ViewPager so we need to clear out the internal state,
// listeners and observers
this.viewPager = null;
setPagerAdapter(null, false);
}
setupViewPagerImplicitly = implicitSetup;
}
/**
* @deprecated Use {@link #setupWithViewPager(ViewPager)} to link a TabLayout with a ViewPager
* together. When that method is used, the TabLayout will be automatically updated when the
* {@link PagerAdapter} is changed.
*/
@Deprecated
public void setTabsFromPagerAdapter(@Nullable final PagerAdapter adapter) {
setPagerAdapter(adapter, false);
}
void updateViewPagerScrollState(int scrollState) {
this.viewPagerScrollState = scrollState;
}
@Override
public boolean shouldDelayChildPressedState() {
// Only delay the pressed state if the tabs can scroll
return getTabScrollRange() > 0;
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
MaterialShapeUtils.setParentAbsoluteElevation(this);
if (viewPager == null) {
// If we don't have a ViewPager already, check if our parent is a ViewPager to
// setup with it automatically
final ViewParent vp = getParent();
if (vp instanceof ViewPager) {
// If we have a ViewPager parent and we've been added as part of its decor, let's
// assume that we should automatically setup to display any titles
setupWithViewPager((ViewPager) vp, true, true);
}
}
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (setupViewPagerImplicitly) {
// If we've been setup with a ViewPager implicitly, let's clear out any listeners, etc
setupWithViewPager(null);
setupViewPagerImplicitly = false;
}
}
private int getTabScrollRange() {
return Math.max(
0, slidingTabIndicator.getWidth() - getWidth() - getPaddingLeft() - getPaddingRight());
}
void setPagerAdapter(@Nullable final PagerAdapter adapter, final boolean addObserver) {
if (pagerAdapter != null && pagerAdapterObserver != null) {
// If we already have a PagerAdapter, unregister our observer
pagerAdapter.unregisterDataSetObserver(pagerAdapterObserver);
}
pagerAdapter = adapter;
if (addObserver && adapter != null) {
// Register our observer on the new adapter
if (pagerAdapterObserver == null) {
pagerAdapterObserver = new PagerAdapterObserver();
}
adapter.registerDataSetObserver(pagerAdapterObserver);
}
// Finally make sure we reflect the new adapter
populateFromPagerAdapter();
}
void populateFromPagerAdapter() {
removeAllTabs();
if (pagerAdapter != null) {
final int adapterCount = pagerAdapter.getCount();
for (int i = 0; i < adapterCount; i++) {
addTab(newTab().setText(pagerAdapter.getPageTitle(i)), false);
}
// Make sure we reflect the currently set ViewPager item
if (viewPager != null && adapterCount > 0) {
final int curItem = viewPager.getCurrentItem();
if (curItem != getSelectedTabPosition() && curItem < getTabCount()) {
selectTab(getTabAt(curItem));
}
}
}
}
private void updateAllTabs() {
for (int i = 0, z = tabs.size(); i < z; i++) {
tabs.get(i).updateView();
}
}
@NonNull
private TabView createTabView(@NonNull final Tab tab) {
TabView tabView = tabViewPool != null ? tabViewPool.acquire() : null;
if (tabView == null) {
tabView = new TabView(getContext());
}
tabView.setTab(tab);
tabView.setFocusable(true);
tabView.setMinimumWidth(getTabMinWidth());
if (TextUtils.isEmpty(tab.contentDesc)) {
tabView.setContentDescription(tab.text);
} else {
tabView.setContentDescription(tab.contentDesc);
}
return tabView;
}
private void configureTab(@NonNull Tab tab, int position) {
tab.setPosition(position);
tabs.add(position, tab);
final int count = tabs.size();
int newIndicatorPosition = -1;
for (int i = position + 1; i < count; i++) {
// If the current tab position is the indicator position, mark its new position as the new
// indicator position.
if (tabs.get(i).getPosition() == indicatorPosition) {
newIndicatorPosition = i;
}
tabs.get(i).setPosition(i);
}
indicatorPosition = newIndicatorPosition;
}
private void addTabView(@NonNull Tab tab) {
final TabView tabView = tab.view;
tabView.setSelected(false);
tabView.setActivated(false);
slidingTabIndicator.addView(tabView, tab.getPosition(), createLayoutParamsForTabs());
}
@Override
public void addView(View child) {
addViewInternal(child);
}
@Override
public void addView(View child, int index) {
addViewInternal(child);
}
@Override
public void addView(View child, ViewGroup.LayoutParams params) {
addViewInternal(child);
}
@Override
public void addView(View child, int index, ViewGroup.LayoutParams params) {
addViewInternal(child);
}
private void addViewInternal(final View child) {
if (child instanceof TabItem) {
addTabFromItemView((TabItem) child);
} else {
throw new IllegalArgumentException("Only TabItem instances can be added to TabLayout");
}
}
@NonNull
private LinearLayout.LayoutParams createLayoutParamsForTabs() {
final LinearLayout.LayoutParams lp =
new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT);
updateTabViewLayoutParams(lp);
return lp;
}
private void updateTabViewLayoutParams(@NonNull LinearLayout.LayoutParams lp) {
if (mode == MODE_FIXED && tabGravity == GRAVITY_FILL) {
lp.width = 0;
lp.weight = 1;
} else {
lp.width = LinearLayout.LayoutParams.WRAP_CONTENT;
lp.weight = 0;
}
}
@RequiresApi(VERSION_CODES.LOLLIPOP)
@Override
public void setElevation(float elevation) {
super.setElevation(elevation);
MaterialShapeUtils.setElevation(this, elevation);
}
@Override
public void onInitializeAccessibilityNodeInfo(@NonNull AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
AccessibilityNodeInfoCompat infoCompat = AccessibilityNodeInfoCompat.wrap(info);
infoCompat.setCollectionInfo(
CollectionInfoCompat.obtain(
/* rowCount= */ 1,
/* columnCount= */ getTabCount(),
/* hierarchical= */ false,
/* selectionMode = */ CollectionInfoCompat.SELECTION_MODE_SINGLE));
}
@Override
protected void onDraw(@NonNull Canvas canvas) {
// Draw tab background layer for each tab item
for (int i = 0; i < slidingTabIndicator.getChildCount(); i++) {
View tabView = slidingTabIndicator.getChildAt(i);
if (tabView instanceof TabView) {
((TabView) tabView).drawBackground(canvas);
}
}
super.onDraw(canvas);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// If we have a MeasureSpec which allows us to decide our height, try and use the default
// height
final int idealHeight = Math.round(ViewUtils.dpToPx(getContext(), getDefaultHeight()));
switch (MeasureSpec.getMode(heightMeasureSpec)) {
case MeasureSpec.AT_MOST:
if (getChildCount() == 1 && MeasureSpec.getSize(heightMeasureSpec) >= idealHeight) {
getChildAt(0).setMinimumHeight(idealHeight);
}
break;
case MeasureSpec.UNSPECIFIED:
heightMeasureSpec =
MeasureSpec.makeMeasureSpec(
idealHeight + getPaddingTop() + getPaddingBottom(), MeasureSpec.EXACTLY);
break;
default:
break;
}
final int specWidth = MeasureSpec.getSize(widthMeasureSpec);
if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.UNSPECIFIED) {
// If we don't have an unspecified width spec, use the given size to calculate
// the max tab width
tabMaxWidth =
requestedTabMaxWidth > 0
? requestedTabMaxWidth
: (int) (specWidth - ViewUtils.dpToPx(getContext(), TAB_MIN_WIDTH_MARGIN));
}
// Now super measure itself using the (possibly) modified height spec
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (getChildCount() == 1) {
// If we're in fixed mode then we need to make sure the tab strip is the same width as us
// so we don't scroll
final View child = getChildAt(0);
boolean remeasure = false;
switch (mode) {
case MODE_AUTO:
case MODE_SCROLLABLE:
// We only need to resize the child if it's smaller than us. This is similar
// to fillViewport
remeasure = child.getMeasuredWidth() < getMeasuredWidth();
break;
case MODE_FIXED:
// Resize the child so that it doesn't scroll
remeasure = child.getMeasuredWidth() != getMeasuredWidth();
break;
}
if (remeasure) {
// Re-measure the child with a widthSpec set to be exactly our measure width
int childHeightMeasureSpec =
getChildMeasureSpec(
heightMeasureSpec,
getPaddingTop() + getPaddingBottom(),
child.getLayoutParams().height);
int childWidthMeasureSpec =
MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
}
}
private void removeTabViewAt(int position) {
final TabView view = (TabView) slidingTabIndicator.getChildAt(position);
slidingTabIndicator.removeViewAt(position);
if (view != null) {
view.reset();
tabViewPool.release(view);
}
requestLayout();
}
private void animateToTab(int newPosition) {
if (newPosition == Tab.INVALID_POSITION) {
return;
}
if (getWindowToken() == null
|| !ViewCompat.isLaidOut(this)
|| slidingTabIndicator.childrenNeedLayout()) {
// If we don't have a window token, or we haven't been laid out yet just draw the new
// position now
setScrollPosition(newPosition, 0f, true);
return;
}
final int startScrollX = getScrollX();
final int targetScrollX = calculateScrollXForTab(newPosition, 0);
if (startScrollX != targetScrollX) {
ensureScrollAnimator();
scrollAnimator.setIntValues(startScrollX, targetScrollX);
scrollAnimator.start();
}
// Now animate the indicator
slidingTabIndicator.animateIndicatorToPosition(newPosition, tabIndicatorAnimationDuration);
}
private void ensureScrollAnimator() {
if (scrollAnimator == null) {
scrollAnimator = new ValueAnimator();
scrollAnimator.setInterpolator(tabIndicatorTimeInterpolator);
scrollAnimator.setDuration(tabIndicatorAnimationDuration);
scrollAnimator.addUpdateListener(
new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(@NonNull ValueAnimator animator) {
scrollTo((int) animator.getAnimatedValue(), 0);
}
});
}
}
void setScrollAnimatorListener(ValueAnimator.AnimatorListener listener) {
ensureScrollAnimator();
scrollAnimator.addListener(listener);
}
/**
* Called when a tab is selected. Unselects all other tabs in the TabLayout.
*
* @param position Position of the selected tab.
*/
private void setSelectedTabView(int position) {
final int tabCount = slidingTabIndicator.getChildCount();
if (position < tabCount) {
for (int i = 0; i < tabCount; i++) {
final View child = slidingTabIndicator.getChildAt(i);
// Update the tab view if it needs to be updated (eg. it's newly selected and it is not
// yet selected, or it is selected and something else was selected).
if ((i == position && !child.isSelected()) || (i != position && child.isSelected())) {
child.setSelected(i == position);
child.setActivated(i == position);
if (child instanceof TabView) {
((TabView) child).updateTab();
}
continue;
}
child.setSelected(i == position);
child.setActivated(i == position);
}
}
}
/**
* Selects the given tab.
*
* @param tab The tab to select, or {@code null} to select none.
* @see #selectTab(Tab, boolean)
*/
public void selectTab(@Nullable Tab tab) {
selectTab(tab, true);
}
/**
* Selects the given tab. Will always animate to the selected tab if the current tab is
* reselected, regardless of the value of {@code updateIndicator}.
*
* @param tab The tab to select, or {@code null} to select none.
* @param updateIndicator Whether to update the indicator.
* @see #selectTab(Tab)
*/
public void selectTab(@Nullable final Tab tab, boolean updateIndicator) {
final Tab currentTab = selectedTab;
if (currentTab == tab) {
if (currentTab != null) {
dispatchTabReselected(tab);
animateToTab(tab.getPosition());
}
} else {
final int newPosition = tab != null ? tab.getPosition() : Tab.INVALID_POSITION;
if (updateIndicator) {
if ((currentTab == null || currentTab.getPosition() == Tab.INVALID_POSITION)
&& newPosition != Tab.INVALID_POSITION) {
// If we don't currently have a tab, just draw the indicator
setScrollPosition(newPosition, 0f, true);
} else {
animateToTab(newPosition);
}
if (newPosition != Tab.INVALID_POSITION) {
setSelectedTabView(newPosition);
}
}
// Setting selectedTab before dispatching 'tab unselected' events, so that currentTab's state
// will be interpreted as unselected
selectedTab = tab;
// If the current tab is still attached to the TabLayout.
if (currentTab != null && currentTab.parent != null) {
dispatchTabUnselected(currentTab);
}
if (tab != null) {
dispatchTabSelected(tab);
}
}
}
private void dispatchTabSelected(@NonNull final Tab tab) {
for (int i = selectedListeners.size() - 1; i >= 0; i--) {
selectedListeners.get(i).onTabSelected(tab);
}
}
private void dispatchTabUnselected(@NonNull final Tab tab) {
for (int i = selectedListeners.size() - 1; i >= 0; i--) {
selectedListeners.get(i).onTabUnselected(tab);
}
}
private void dispatchTabReselected(@NonNull final Tab tab) {
for (int i = selectedListeners.size() - 1; i >= 0; i--) {
selectedListeners.get(i).onTabReselected(tab);
}
}
private int calculateScrollXForTab(int position, float positionOffset) {
if (mode == MODE_SCROLLABLE || mode == MODE_AUTO) {
final View selectedChild = slidingTabIndicator.getChildAt(position);
if (selectedChild == null) {
return 0;
}
final View nextChild =
position + 1 < slidingTabIndicator.getChildCount()
? slidingTabIndicator.getChildAt(position + 1)
: null;
final int selectedWidth = selectedChild.getWidth();
final int nextWidth = nextChild != null ? nextChild.getWidth() : 0;
// base scroll amount: places center of tab in center of parent
int scrollBase = selectedChild.getLeft() + (selectedWidth / 2) - (getWidth() / 2);
// offset amount: fraction of the distance between centers of tabs
int scrollOffset = (int) ((selectedWidth + nextWidth) * 0.5f * positionOffset);
return (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_LTR)
? scrollBase + scrollOffset
: scrollBase - scrollOffset;
}
return 0;
}
private void applyModeAndGravity() {
int paddingStart = 0;
if (mode == MODE_SCROLLABLE || mode == MODE_AUTO) {
// If we're scrollable, or fixed at start, inset using padding
paddingStart = Math.max(0, contentInsetStart - tabPaddingStart);
}
ViewCompat.setPaddingRelative(slidingTabIndicator, paddingStart, 0, 0, 0);
switch (mode) {
case MODE_AUTO:
case MODE_FIXED:
if (tabGravity == GRAVITY_START) {
Log.w(
LOG_TAG,
"GRAVITY_START is not supported with the current tab mode, GRAVITY_CENTER will be"
+ " used instead");
}
slidingTabIndicator.setGravity(Gravity.CENTER_HORIZONTAL);
break;
case MODE_SCROLLABLE:
applyGravityForModeScrollable(tabGravity);
break;
}
updateTabViews(true);
}
private void applyGravityForModeScrollable(int tabGravity) {
switch (tabGravity) {
case GRAVITY_CENTER:
slidingTabIndicator.setGravity(Gravity.CENTER_HORIZONTAL);
break;
case GRAVITY_FILL:
Log.w(
LOG_TAG,
"MODE_SCROLLABLE + GRAVITY_FILL is not supported, GRAVITY_START will be used"
+ " instead");
// Fall through
case GRAVITY_START:
slidingTabIndicator.setGravity(GravityCompat.START);
break;
default:
break;
}
}
void updateTabViews(final boolean requestLayout) {
for (int i = 0; i < slidingTabIndicator.getChildCount(); i++) {
View child = slidingTabIndicator.getChildAt(i);
child.setMinimumWidth(getTabMinWidth());
updateTabViewLayoutParams((LinearLayout.LayoutParams) child.getLayoutParams());
if (requestLayout) {
child.requestLayout();
}
}
}
/** A tab in this layout. Instances can be created via {@link #newTab()}. */
// TODO(b/76413401): make class final after the widget migration is finished
public static class Tab {
/**
* An invalid position for a tab.
*
* @see #getPosition()
*/
public static final int INVALID_POSITION = -1;
@Nullable private Object tag;
@Nullable private Drawable icon;
@Nullable private CharSequence text;
// This represents the content description that has been explicitly set on the Tab or TabItem
// in XML or through #setContentDescription. If the content description is empty, text should
// be used as the content description instead, but contentDesc should remain empty.
@Nullable private CharSequence contentDesc;
private int position = INVALID_POSITION;
@Nullable private View customView;
private @LabelVisibility int labelVisibilityMode = TAB_LABEL_VISIBILITY_LABELED;
// TODO(b/76413401): make package private after the widget migration is finished
@Nullable public TabLayout parent;
// TODO(b/76413401): make package private after the widget migration is finished
@NonNull public TabView view;
private int id = NO_ID;
// TODO(b/76413401): make package private constructor after the widget migration is finished
public Tab() {
// Private constructor
}
/** @return This Tab's tag object. */
@Nullable
public Object getTag() {
return tag;
}
/**
* Give this Tab an arbitrary object to hold for later use.
*
* @param tag Object to store
* @return The current instance for call chaining
*/
@NonNull
@CanIgnoreReturnValue
public Tab setTag(@Nullable Object tag) {
this.tag = tag;
return this;
}
/**
* Give this tab an id, useful for testing.
*
* Do not rely on this if using {@link TabLayout#setupWithViewPager(ViewPager)}
*
* @param id, unique id for this tab
*/
@NonNull
@CanIgnoreReturnValue
public Tab setId(int id) {
this.id = id;
if (view != null) {
view.setId(id);
}
return this;
}
/** Returns the id for this tab, {@code View.NO_ID} if not set. */
public int getId() {
return id;
}
/**
* Returns the custom view used for this tab.
*
* @see #setCustomView(View)
* @see #setCustomView(int)
*/
@Nullable
public View getCustomView() {
return customView;
}
/**
* Set a custom view to be used for this tab.
*
* If the provided view contains a {@link TextView} with an ID of {@link android.R.id#text1}
* then that will be updated with the value given to {@link #setText(CharSequence)}. Similarly,
* if this layout contains an {@link ImageView} with ID {@link android.R.id#icon} then it will
* be updated with the value given to {@link #setIcon(Drawable)}.
*
* @param view Custom view to be used as a tab.
* @return The current instance for call chaining
*/
@NonNull
@CanIgnoreReturnValue
public Tab setCustomView(@Nullable View view) {
customView = view;
updateView();
return this;
}
/**
* Set a custom view to be used for this tab.
*
* If the inflated layout contains a {@link TextView} with an ID of {@link
* android.R.id#text1} then that will be updated with the value given to {@link
* #setText(CharSequence)}. Similarly, if this layout contains an {@link ImageView} with ID
* {@link android.R.id#icon} then it will be updated with the value given to {@link
* #setIcon(Drawable)}.
*
* @param resId A layout resource to inflate and use as a custom tab view
* @return The current instance for call chaining
*/
@NonNull
@CanIgnoreReturnValue
public Tab setCustomView(@LayoutRes int resId) {
final LayoutInflater inflater = LayoutInflater.from(view.getContext());
return setCustomView(inflater.inflate(resId, view, false));
}
/**
* Return the icon associated with this tab.
*
* @return The tab's icon
*/
@Nullable
public Drawable getIcon() {
return icon;
}
/**
* Return the current position of this tab in the tab layout.
*
* @return Current position, or {@link #INVALID_POSITION} if this tab is not currently in the
* tab layout.
*/
public int getPosition() {
return position;
}
void setPosition(int position) {
this.position = position;
}
/**
* Return the text of this tab.
*
* @return The tab's text
*/
@Nullable
public CharSequence getText() {
return text;
}
/**
* Set the icon displayed on this tab.
*
* @param icon The drawable to use as an icon
* @return The current instance for call chaining
*/
@NonNull
@CanIgnoreReturnValue
public Tab setIcon(@Nullable Drawable icon) {
this.icon = icon;
if ((parent.tabGravity == GRAVITY_CENTER) || parent.mode == MODE_AUTO) {
parent.updateTabViews(true);
}
updateView();
if (BadgeUtils.USE_COMPAT_PARENT
&& view.hasBadgeDrawable()
&& view.badgeDrawable.isVisible()) {
// Invalidate the TabView if icon visibility has changed and a badge is displayed.
view.invalidate();
}
return this;
}
/**
* Set the icon displayed on this tab.
*
* @param resId A resource ID referring to the icon that should be displayed
* @return The current instance for call chaining
*/
@NonNull
@CanIgnoreReturnValue
public Tab setIcon(@DrawableRes int resId) {
if (parent == null) {
throw new IllegalArgumentException("Tab not attached to a TabLayout");
}
return setIcon(AppCompatResources.getDrawable(parent.getContext(), resId));
}
/**
* Set the text displayed on this tab. Text may be truncated if there is not room to display the
* entire string.
*
* @param text The text to display
* @return The current instance for call chaining
*/
@NonNull
@CanIgnoreReturnValue
public Tab setText(@Nullable CharSequence text) {
if (TextUtils.isEmpty(contentDesc) && !TextUtils.isEmpty(text)) {
// If no content description has been set, use the text as the content description of the
// TabView. If the text is null, don't update the content description.
view.setContentDescription(text);
}
this.text = text;
updateView();
return this;
}
/**
* Set the text displayed on this tab. Text may be truncated if there is not room to display the
* entire string.
*
* @param resId A resource ID referring to the text that should be displayed
* @return The current instance for call chaining
*/
@NonNull
@CanIgnoreReturnValue
public Tab setText(@StringRes int resId) {
if (parent == null) {
throw new IllegalArgumentException("Tab not attached to a TabLayout");
}
return setText(parent.getResources().getText(resId));
}
/**
* Creates an instance of {@link BadgeDrawable} if none exists. Initializes (if needed) and
* returns the associated instance of {@link BadgeDrawable}.
*
* @return an instance of BadgeDrawable associated with {@code Tab}.
*/
@NonNull
public BadgeDrawable getOrCreateBadge() {
return view.getOrCreateBadge();
}
/**
* Removes the {@link BadgeDrawable}. Do nothing if none exists. Consider changing the
* visibility of the {@link BadgeDrawable} if you only want to hide it temporarily.
*/
public void removeBadge() {
view.removeBadge();
}
/**
* Returns an instance of {@link BadgeDrawable} associated with this tab, null if none was
* initialized.
*/
@Nullable
public BadgeDrawable getBadge() {
return view.getBadge();
}
/**
* Sets the visibility mode for the Labels in this Tab. The valid input options are:
*
* This method is called for each frame when either animating the indicator between
* destinations or driving an animation through gesture, such as with a viewpager.
*
* @param startTitle The tab which should be selected (as marked by the indicator), when
* fraction is 0.0.
* @param endTitle The tab which should be selected (as marked by the indicator), when fraction
* is 1.0.
* @param fraction A value between 0.0 and 1.0 that indicates how far between currentTitle and
* endTitle the indicator should be drawn. e.g. If a viewpager attached to this TabLayout is
* currently half way slid between page 0 and page 1, fraction will be 0.5.
*/
private void tweenIndicatorPosition(View startTitle, View endTitle, float fraction) {
boolean hasVisibleTitle = startTitle != null && startTitle.getWidth() > 0;
if (hasVisibleTitle) {
tabIndicatorInterpolator.updateIndicatorForOffset(
TabLayout.this, startTitle, endTitle, fraction, tabSelectedIndicator);
} else {
// Hide the indicator by setting the drawable's width to 0 and off screen.
tabSelectedIndicator.setBounds(
-1, tabSelectedIndicator.getBounds().top, -1, tabSelectedIndicator.getBounds().bottom);
}
ViewCompat.postInvalidateOnAnimation(this);
}
/**
* Animate the position of the indicator from its current position to a new position.
*
* This is typically used when a tab destination is tapped. If the indicator should be moved
* as a result of a gesture, see {@link #setIndicatorPositionFromTabPosition(int, float)}.
*
* @param position The new position to animate the indicator to.
* @param duration The duration over which the animation should take place.
*/
void animateIndicatorToPosition(final int position, int duration) {
if (indicatorAnimator != null
&& indicatorAnimator.isRunning()
&& indicatorPosition != position) {
indicatorAnimator.cancel();
}
updateOrRecreateIndicatorAnimation(/* recreateAnimation= */ true, position, duration);
}
/**
* Animate the position of the indicator from its current position to a new position.
*
* @param recreateAnimation Whether a currently running animator should be re-targeted to move
* the indicator to it's new position.
* @param position The new position to animate the indicator to.
* @param duration The duration over which the animation should take place.
*/
private void updateOrRecreateIndicatorAnimation(
boolean recreateAnimation, final int position, int duration) {
// If the indicator position is already the target position, we don't need to update the
// indicator animation because nothing has changed.
if (indicatorPosition == position) {
return;
}
final View currentView = getChildAt(getSelectedTabPosition());
final View targetView = getChildAt(position);
if (targetView == null) {
// If we don't have a view, just update the position now and return
jumpIndicatorToSelectedPosition();
return;
}
indicatorPosition = position;
// Create the update listener with the new target indicator positions. If we're not recreating
// then animationStartLeft/Right will be the same as when the previous animator was created.
ValueAnimator.AnimatorUpdateListener updateListener =
new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(@NonNull ValueAnimator valueAnimator) {
tweenIndicatorPosition(currentView, targetView, valueAnimator.getAnimatedFraction());
}
};
if (recreateAnimation) {
// Create & start a new indicatorAnimator.
ValueAnimator animator = indicatorAnimator = new ValueAnimator();
animator.setInterpolator(tabIndicatorTimeInterpolator);
animator.setDuration(duration);
animator.setFloatValues(0F, 1F);
animator.addUpdateListener(updateListener);
animator.start();
} else {
// Reuse the existing animator. Updating the listener only modifies the target positions.
indicatorAnimator.removeAllUpdateListeners();
indicatorAnimator.addUpdateListener(updateListener);
}
}
@Override
public void draw(@NonNull Canvas canvas) {
int indicatorHeight = tabSelectedIndicator.getBounds().height();
if (indicatorHeight < 0) {
indicatorHeight = tabSelectedIndicator.getIntrinsicHeight();
}
int indicatorTop = 0;
int indicatorBottom = 0;
switch (tabIndicatorGravity) {
case INDICATOR_GRAVITY_BOTTOM:
indicatorTop = getHeight() - indicatorHeight;
indicatorBottom = getHeight();
break;
case INDICATOR_GRAVITY_CENTER:
indicatorTop = (getHeight() - indicatorHeight) / 2;
indicatorBottom = (getHeight() + indicatorHeight) / 2;
break;
case INDICATOR_GRAVITY_TOP:
indicatorTop = 0;
indicatorBottom = indicatorHeight;
break;
case INDICATOR_GRAVITY_STRETCH:
indicatorTop = 0;
indicatorBottom = getHeight();
break;
default:
break;
}
// Ensure the drawable actually has a width and is worth drawing
if (tabSelectedIndicator.getBounds().width() > 0) {
// Use the left and right bounds of the drawable, as set by the indicator interpolator.
// Update the top and bottom to respect the indicator gravity property.
Rect indicatorBounds = tabSelectedIndicator.getBounds();
tabSelectedIndicator.setBounds(
indicatorBounds.left, indicatorTop, indicatorBounds.right, indicatorBottom);
tabSelectedIndicator.draw(canvas);
}
// Draw the tab item contents (icon and label) on top of the background + indicator layers
super.draw(canvas);
}
}
@NonNull
private static ColorStateList createColorStateList(int defaultColor, int selectedColor) {
final int[][] states = new int[2][];
final int[] colors = new int[2];
int i = 0;
states[i] = SELECTED_STATE_SET;
colors[i] = selectedColor;
i++;
// Default enabled state
states[i] = EMPTY_STATE_SET;
colors[i] = defaultColor;
i++;
return new ColorStateList(states, colors);
}
@Dimension(unit = Dimension.DP)
private int getDefaultHeight() {
boolean hasIconAndText = false;
for (int i = 0, count = tabs.size(); i < count; i++) {
Tab tab = tabs.get(i);
if (tab != null && tab.getIcon() != null && !TextUtils.isEmpty(tab.getText())) {
hasIconAndText = true;
break;
}
}
return (hasIconAndText && !inlineLabel) ? DEFAULT_HEIGHT_WITH_TEXT_ICON : DEFAULT_HEIGHT;
}
private int getTabMinWidth() {
if (requestedTabMinWidth != INVALID_WIDTH) {
// If we have been given a min width, use it
return requestedTabMinWidth;
}
// Else, we'll use the default value
return (mode == MODE_SCROLLABLE || mode == MODE_AUTO) ? scrollableTabMinWidth : 0;
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
// We don't care about the layout params of any views added to us, since we don't actually
// add them. The only view we add is the SlidingTabStrip, which is done manually.
// We return the default layout params so that we don't blow up if we're given a TabItem
// without android:layout_* values.
return generateDefaultLayoutParams();
}
int getTabMaxWidth() {
return tabMaxWidth;
}
/**
* A {@link ViewPager.OnPageChangeListener} class which contains the necessary calls back to the
* provided {@link TabLayout} so that the tab position is kept in sync.
*
* This class stores the provided TabLayout weakly, meaning that you can use {@link
* ViewPager#addOnPageChangeListener(ViewPager.OnPageChangeListener)
* addOnPageChangeListener(OnPageChangeListener)} without removing the listener and not cause a
* leak.
*/
public static class TabLayoutOnPageChangeListener implements ViewPager.OnPageChangeListener {
@NonNull private final WeakReferenceposition. If this is the
* first tab to be added it will become the selected tab.
*
* @param tab The tab to add
* @param position The new position of the tab
*/
public void addTab(@NonNull Tab tab, int position) {
addTab(tab, position, tabs.isEmpty());
}
/**
* Add a tab to this layout. The tab will be added at the end of the list.
*
* @param tab Tab to add
* @param setSelected True if the added tab should become the selected tab.
*/
public void addTab(@NonNull Tab tab, boolean setSelected) {
addTab(tab, tabs.size(), setSelected);
}
/**
* Add a tab to this layout. The tab will be inserted at position.
*
* @param tab The tab to add
* @param position The new position of the tab
* @param setSelected True if the added tab should become the selected tab.
*/
public void addTab(@NonNull Tab tab, int position, boolean setSelected) {
if (tab.parent != this) {
throw new IllegalArgumentException("Tab belongs to a different TabLayout.");
}
configureTab(tab, position);
addTabView(tab);
if (setSelected) {
tab.select();
}
}
private void addTabFromItemView(@NonNull TabItem item) {
final Tab tab = newTab();
if (item.text != null) {
tab.setText(item.text);
}
if (item.icon != null) {
tab.setIcon(item.icon);
}
if (item.customLayout != 0) {
tab.setCustomView(item.customLayout);
}
if (!TextUtils.isEmpty(item.getContentDescription())) {
tab.setContentDescription(item.getContentDescription());
}
addTab(tab);
}
private boolean isScrollingEnabled() {
return getTabMode() == MODE_SCROLLABLE || getTabMode() == MODE_AUTO;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
// When a touch event is intercepted and the tab mode is fixed, do not continue to process the
// touch event. This will prevent unexpected scrolling from occurring in corner cases (i.e. a
// layout in fixed mode that has padding should not scroll for the width of the padding).
return isScrollingEnabled() && super.onInterceptTouchEvent(event);
}
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getActionMasked() == MotionEvent.ACTION_SCROLL && !isScrollingEnabled()) {
return false;
}
return super.onTouchEvent(event);
}
/**
* @deprecated Use {@link #addOnTabSelectedListener(OnTabSelectedListener)} and {@link
* #removeOnTabSelectedListener(OnTabSelectedListener)}.
*/
@Deprecated
public void setOnTabSelectedListener(@Nullable OnTabSelectedListener listener) {
setOnTabSelectedListener((BaseOnTabSelectedListener) listener);
}
/**
* @deprecated Use {@link #addOnTabSelectedListener(OnTabSelectedListener)} and {@link
* #removeOnTabSelectedListener(OnTabSelectedListener)}.
*/
@Deprecated
public void setOnTabSelectedListener(@Nullable BaseOnTabSelectedListener listener) {
// The logic in this method emulates what we had before support for multiple
// registered listeners.
if (selectedListener != null) {
removeOnTabSelectedListener(selectedListener);
}
// Update the deprecated field so that we can remove the passed listener the next
// time we're called
selectedListener = listener;
if (listener != null) {
addOnTabSelectedListener(listener);
}
}
/**
* Add a {@link TabLayout.OnTabSelectedListener} that will be invoked when tab selection changes.
*
*
*
*
* @param mode one of {@link #MODE_FIXED} or {@link #MODE_SCROLLABLE}.
* @attr ref com.google.android.material.R.styleable#TabLayout_tabMode
*/
public void setTabMode(@Mode int mode) {
if (mode != this.mode) {
this.mode = mode;
applyModeAndGravity();
}
}
/**
* Returns the current mode used by this {@link TabLayout}.
*
* @see #setTabMode(int)
*/
@Mode
public int getTabMode() {
return mode;
}
/**
* Set the gravity to use when laying out the tabs.
*
* @param gravity one of {@link #GRAVITY_CENTER} or {@link #GRAVITY_FILL}.
* @attr ref com.google.android.material.R.styleable#TabLayout_tabGravity
*/
public void setTabGravity(@TabGravity int gravity) {
if (tabGravity != gravity) {
tabGravity = gravity;
applyModeAndGravity();
}
}
/**
* The current gravity used for laying out tabs.
*
* @return one of {@link #GRAVITY_CENTER} or {@link #GRAVITY_FILL}.
*/
@TabGravity
public int getTabGravity() {
return tabGravity;
}
/**
* Set the indicator gravity used to align the tab selection indicator in the {@link TabLayout}.
* You must set the indicator height via the custom indicator drawable's intrinsic height
* (preferred), via the {@code tabIndicatorHeight} attribute (deprecated), or via {@link
* #setSelectedTabIndicatorHeight(int)} (deprecated). Otherwise, the indicator will not be shown
* unless gravity is set to {@link #INDICATOR_GRAVITY_STRETCH}, in which case it will ignore
* indicator height and stretch across the entire height of the {@link TabLayout}. This
* defaults to {@link #INDICATOR_GRAVITY_BOTTOM} if not set.
*
* @param indicatorGravity one of {@link #INDICATOR_GRAVITY_BOTTOM}, {@link
* #INDICATOR_GRAVITY_CENTER}, {@link #INDICATOR_GRAVITY_TOP}, or {@link
* #INDICATOR_GRAVITY_STRETCH}
* @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorGravity
*/
public void setSelectedTabIndicatorGravity(@TabIndicatorGravity int indicatorGravity) {
if (tabIndicatorGravity != indicatorGravity) {
tabIndicatorGravity = indicatorGravity;
ViewCompat.postInvalidateOnAnimation(slidingTabIndicator);
}
}
/**
* Get the current indicator gravity used to align the tab selection indicator in the {@link
* TabLayout}.
*
* @return one of {@link #INDICATOR_GRAVITY_BOTTOM}, {@link #INDICATOR_GRAVITY_CENTER}, {@link
* #INDICATOR_GRAVITY_TOP}, or {@link #INDICATOR_GRAVITY_STRETCH}
*/
@TabIndicatorGravity
public int getTabIndicatorGravity() {
return tabIndicatorGravity;
}
/**
* Set the mode by which the selection indicator should animate when moving between destinations.
*
*
*
*
* @param mode one of {@link #TAB_LABEL_VISIBILITY_UNLABELED} or {@link
* #TAB_LABEL_VISIBILITY_LABELED}.
* @return The current instance for call chaining.
*/
@NonNull
@CanIgnoreReturnValue
public Tab setTabLabelVisibility(@LabelVisibility int mode) {
this.labelVisibilityMode = mode;
if ((parent.tabGravity == GRAVITY_CENTER) || parent.mode == MODE_AUTO) {
parent.updateTabViews(true);
}
this.updateView();
if (BadgeUtils.USE_COMPAT_PARENT
&& view.hasBadgeDrawable()
&& view.badgeDrawable.isVisible()) {
// Invalidate the TabView if label visibility has changed and a badge is displayed.
view.invalidate();
}
return this;
}
/**
* Gets the visibility mode for the Labels in this Tab.
*
* @return the label visibility mode, one of {@link #TAB_LABEL_VISIBILITY_UNLABELED} or {@link
* #TAB_LABEL_VISIBILITY_LABELED}.
* @see #setTabLabelVisibility(int)
*/
@LabelVisibility
public int getTabLabelVisibility() {
return this.labelVisibilityMode;
}
/** Select this tab. Only valid if the tab has been added to the tab layout. */
public void select() {
if (parent == null) {
throw new IllegalArgumentException("Tab not attached to a TabLayout");
}
parent.selectTab(this);
}
/** Returns true if this tab is currently selected. */
public boolean isSelected() {
if (parent == null) {
throw new IllegalArgumentException("Tab not attached to a TabLayout");
}
int selectedPosition = parent.getSelectedTabPosition();
return selectedPosition != INVALID_POSITION && selectedPosition == position;
}
/**
* Set a description of this tab's content for use in accessibility support. If no content
* description is provided the title will be used.
*
* @param resId A resource ID referring to the description text
* @return The current instance for call chaining
* @see #setContentDescription(CharSequence)
* @see #getContentDescription()
*/
@NonNull
@CanIgnoreReturnValue
public Tab setContentDescription(@StringRes int resId) {
if (parent == null) {
throw new IllegalArgumentException("Tab not attached to a TabLayout");
}
return setContentDescription(parent.getResources().getText(resId));
}
/**
* Set a description of this tab's content for use in accessibility support. If no content
* description is provided the title will be used.
*
* @param contentDesc Description of this tab's content
* @return The current instance for call chaining
* @see #setContentDescription(int)
* @see #getContentDescription()
*/
@NonNull
@CanIgnoreReturnValue
public Tab setContentDescription(@Nullable CharSequence contentDesc) {
this.contentDesc = contentDesc;
updateView();
return this;
}
/**
* Gets a brief description of this tab's content for use in accessibility support.
*
* @return Description of this tab's content
* @see #setContentDescription(CharSequence)
* @see #setContentDescription(int)
*/
@Nullable
public CharSequence getContentDescription() {
// This returns the view's content description instead of contentDesc because if the title
// is used as a replacement for the content description, contentDesc will be empty.
return (view == null) ? null : view.getContentDescription();
}
void updateView() {
if (view != null) {
view.update();
}
}
void reset() {
parent = null;
view = null;
tag = null;
icon = null;
id = NO_ID;
text = null;
contentDesc = null;
position = INVALID_POSITION;
customView = null;
}
}
/** A {@link LinearLayout} containing {@link Tab} instances for use with {@link TabLayout}. */
public final class TabView extends LinearLayout {
private Tab tab;
private TextView textView;
private ImageView iconView;
@Nullable private View badgeAnchorView;
@Nullable private BadgeDrawable badgeDrawable;
@Nullable private View customView;
@Nullable private TextView customTextView;
@Nullable private ImageView customIconView;
@Nullable private Drawable baseBackgroundDrawable;
private int defaultMaxLines = 2;
public TabView(@NonNull Context context) {
super(context);
updateBackgroundDrawable(context);
ViewCompat.setPaddingRelative(
this, tabPaddingStart, tabPaddingTop, tabPaddingEnd, tabPaddingBottom);
setGravity(Gravity.CENTER);
setOrientation(inlineLabel ? HORIZONTAL : VERTICAL);
setClickable(true);
ViewCompat.setPointerIcon(
this, PointerIconCompat.getSystemIcon(getContext(), PointerIconCompat.TYPE_HAND));
}
private void updateBackgroundDrawable(Context context) {
if (tabBackgroundResId != 0) {
baseBackgroundDrawable = AppCompatResources.getDrawable(context, tabBackgroundResId);
if (baseBackgroundDrawable != null && baseBackgroundDrawable.isStateful()) {
baseBackgroundDrawable.setState(getDrawableState());
}
} else {
baseBackgroundDrawable = null;
}
Drawable background;
Drawable contentDrawable = new GradientDrawable();
((GradientDrawable) contentDrawable).setColor(Color.TRANSPARENT);
if (tabRippleColorStateList != null) {
GradientDrawable maskDrawable = new GradientDrawable();
// TODO: Find a workaround for this. Currently on certain devices/versions,
// LayerDrawable will draw a black background underneath any layer with a non-opaque color,
// (e.g. ripple) unless we set the shape to be something that's not a perfect rectangle.
maskDrawable.setCornerRadius(0.00001F);
maskDrawable.setColor(Color.WHITE);
ColorStateList rippleColor =
RippleUtils.convertToRippleDrawableColor(tabRippleColorStateList);
// TODO: Add support to RippleUtils.compositeRippleColorStateList for different ripple color
// for selected items vs non-selected items
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
background =
new RippleDrawable(
rippleColor,
unboundedRipple ? null : contentDrawable,
unboundedRipple ? null : maskDrawable);
} else {
Drawable rippleDrawable = DrawableCompat.wrap(maskDrawable);
DrawableCompat.setTintList(rippleDrawable, rippleColor);
background = new LayerDrawable(new Drawable[] {contentDrawable, rippleDrawable});
}
} else {
background = contentDrawable;
}
ViewCompat.setBackground(this, background);
TabLayout.this.invalidate();
}
/**
* Draw the background drawable specified by tabBackground attribute onto the canvas provided.
* This method will draw the background to the full bounds of this TabView. We provide a
* separate method for drawing this background rather than just setting this background on the
* TabView so that we can control when this background gets drawn. This allows us to draw the
* tab background underneath the TabLayout selection indicator, and then draw the TabLayout
* content (icons + labels) on top of the selection indicator.
*
* @param canvas canvas to draw the background on
*/
private void drawBackground(@NonNull Canvas canvas) {
if (baseBackgroundDrawable != null) {
baseBackgroundDrawable.setBounds(getLeft(), getTop(), getRight(), getBottom());
baseBackgroundDrawable.draw(canvas);
}
}
@Override
protected void drawableStateChanged() {
super.drawableStateChanged();
boolean changed = false;
int[] state = getDrawableState();
if (baseBackgroundDrawable != null && baseBackgroundDrawable.isStateful()) {
changed |= baseBackgroundDrawable.setState(state);
}
if (changed) {
invalidate();
TabLayout.this.invalidate(); // Invalidate TabLayout, which draws mBaseBackgroundDrawable
}
}
@Override
public boolean performClick() {
final boolean handled = super.performClick();
if (tab != null) {
if (!handled) {
playSoundEffect(SoundEffectConstants.CLICK);
}
tab.select();
return true;
} else {
return handled;
}
}
@Override
public void setSelected(final boolean selected) {
final boolean changed = isSelected() != selected;
super.setSelected(selected);
if (changed && selected && Build.VERSION.SDK_INT < 16) {
// Pre-JB we need to manually send the TYPE_VIEW_SELECTED event
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
}
// Always dispatch this to the child views, regardless of whether the value has
// changed
if (textView != null) {
textView.setSelected(selected);
}
if (iconView != null) {
iconView.setSelected(selected);
}
if (customView != null) {
customView.setSelected(selected);
}
}
@Override
public void onInitializeAccessibilityNodeInfo(@NonNull AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
AccessibilityNodeInfoCompat infoCompat = AccessibilityNodeInfoCompat.wrap(info);
if (badgeDrawable != null && badgeDrawable.isVisible()) {
infoCompat.setContentDescription(badgeDrawable.getContentDescription());
}
infoCompat.setCollectionItemInfo(
CollectionItemInfoCompat.obtain(
/* rowIndex= */ 0,
/* rowSpan= */ 1,
/* columnIndex= */ tab.getPosition(),
/* columnSpan= */ 1,
/* heading= */ false,
/* selected= */ isSelected()));
if (isSelected()) {
infoCompat.setClickable(false);
infoCompat.removeAction(AccessibilityActionCompat.ACTION_CLICK);
}
infoCompat.setRoleDescription(getResources().getString(R.string.item_view_role_description));
}
@Override
public void onMeasure(final int origWidthMeasureSpec, final int origHeightMeasureSpec) {
final int specWidthSize = MeasureSpec.getSize(origWidthMeasureSpec);
final int specWidthMode = MeasureSpec.getMode(origWidthMeasureSpec);
final int maxWidth = getTabMaxWidth();
final int widthMeasureSpec;
final int heightMeasureSpec = origHeightMeasureSpec;
if (maxWidth > 0 && (specWidthMode == MeasureSpec.UNSPECIFIED || specWidthSize > maxWidth)) {
// If we have a max width and a given spec which is either unspecified or
// larger than the max width, update the width spec using the same mode
widthMeasureSpec = MeasureSpec.makeMeasureSpec(tabMaxWidth, MeasureSpec.AT_MOST);
} else {
// Else, use the original width spec
widthMeasureSpec = origWidthMeasureSpec;
}
// Now lets measure
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// We need to switch the text size based on whether the text is spanning 2 lines or not
if (textView != null) {
float textSize = tabTextSize;
int maxLines = defaultMaxLines;
if (iconView != null && iconView.getVisibility() == VISIBLE) {
// If the icon view is being displayed, we limit the text to 1 line
maxLines = 1;
} else if (textView != null && textView.getLineCount() > 1) {
// Otherwise when we have text which wraps we reduce the text size
textSize = tabTextMultiLineSize;
}
final float curTextSize = textView.getTextSize();
final int curLineCount = textView.getLineCount();
final int curMaxLines = TextViewCompat.getMaxLines(textView);
if (textSize != curTextSize || (curMaxLines >= 0 && maxLines != curMaxLines)) {
// We've got a new text size and/or max lines...
boolean updateTextView = true;
if (mode == MODE_FIXED && textSize > curTextSize && curLineCount == 1) {
// If we're in fixed mode, going up in text size and currently have 1 line
// then it's very easy to get into an infinite recursion.
// To combat that we check to see if the change in text size
// will cause a line count change. If so, abort the size change and stick
// to the smaller size.
final Layout layout = textView.getLayout();
if (layout == null
|| approximateLineWidth(layout, 0, textSize)
> getMeasuredWidth() - getPaddingLeft() - getPaddingRight()) {
updateTextView = false;
}
}
if (updateTextView) {
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize);
textView.setMaxLines(maxLines);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
}
}
void setTab(@Nullable final Tab tab) {
if (tab != this.tab) {
this.tab = tab;
update();
}
}
void reset() {
setTab(null);
setSelected(false);
}
final void updateTab() {
final Tab tab = this.tab;
final View custom = tab != null ? tab.getCustomView() : null;
if (custom != null) {
final ViewParent customParent = custom.getParent();
if (customParent != this) {
if (customParent != null) {
((ViewGroup) customParent).removeView(custom);
}
if (customView != null) {
final ViewParent customViewParent = customView.getParent();
if (customViewParent != null) {
((ViewGroup) customViewParent).removeView(customView);
}
}
addView(custom);
}
customView = custom;
if (this.textView != null) {
this.textView.setVisibility(GONE);
}
if (this.iconView != null) {
this.iconView.setVisibility(GONE);
this.iconView.setImageDrawable(null);
}
customTextView = custom.findViewById(android.R.id.text1);
if (customTextView != null) {
defaultMaxLines = TextViewCompat.getMaxLines(customTextView);
}
customIconView = custom.findViewById(android.R.id.icon);
} else {
// We do not have a custom view. Remove one if it already exists
if (customView != null) {
removeView(customView);
customView = null;
}
customTextView = null;
customIconView = null;
}
if (customView == null) {
// If there isn't a custom view, we'll us our own in-built layouts
if (this.iconView == null) {
inflateAndAddDefaultIconView();
}
if (this.textView == null) {
inflateAndAddDefaultTextView();
defaultMaxLines = TextViewCompat.getMaxLines(this.textView);
}
TextViewCompat.setTextAppearance(this.textView, defaultTabTextAppearance);
if (isSelected() && selectedTabTextAppearance != -1) {
TextViewCompat.setTextAppearance(this.textView, selectedTabTextAppearance);
} else {
TextViewCompat.setTextAppearance(this.textView, tabTextAppearance);
}
if (tabTextColors != null) {
this.textView.setTextColor(tabTextColors);
}
updateTextAndIcon(this.textView, this.iconView, /* addDefaultMargins= */ true);
tryUpdateBadgeAnchor();
addOnLayoutChangeListener(iconView);
addOnLayoutChangeListener(textView);
} else {
// Else, we'll see if there is a TextView or ImageView present and update them
if (customTextView != null || customIconView != null) {
updateTextAndIcon(customTextView, customIconView, /* addDefaultMargins= */ false);
}
}
if (tab != null && !TextUtils.isEmpty(tab.contentDesc)) {
// Only update the TabView's content description from Tab if the Tab's content description
// has been explicitly set.
setContentDescription(tab.contentDesc);
}
}
final void update() {
updateTab();
// Finally update our selected state
setSelected(this.tab != null && this.tab.isSelected());
}
private void inflateAndAddDefaultIconView() {
ViewGroup iconViewParent = this;
if (BadgeUtils.USE_COMPAT_PARENT) {
iconViewParent = createPreApi18BadgeAnchorRoot();
addView(iconViewParent, 0);
}
this.iconView =
(ImageView)
LayoutInflater.from(getContext())
.inflate(R.layout.design_layout_tab_icon, iconViewParent, false);
iconViewParent.addView(iconView, 0);
}
private void inflateAndAddDefaultTextView() {
ViewGroup textViewParent = this;
if (BadgeUtils.USE_COMPAT_PARENT) {
textViewParent = createPreApi18BadgeAnchorRoot();
addView(textViewParent);
}
this.textView =
(TextView)
LayoutInflater.from(getContext())
.inflate(R.layout.design_layout_tab_text, textViewParent, false);
textViewParent.addView(textView);
}
@NonNull
private FrameLayout createPreApi18BadgeAnchorRoot() {
FrameLayout frameLayout = new FrameLayout(getContext());
FrameLayout.LayoutParams layoutparams =
new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
frameLayout.setLayoutParams(layoutparams);
return frameLayout;
}
/**
* Creates an instance of {@link BadgeDrawable} if none exists. Initializes (if needed) and
* returns the associated instance of {@link BadgeDrawable}.
*
* @return an instance of BadgeDrawable associated with {@code Tab}.
*/
@NonNull
private BadgeDrawable getOrCreateBadge() {
// Creates a new instance if one is not already initialized for this TabView.
if (badgeDrawable == null) {
badgeDrawable = BadgeDrawable.create(getContext());
}
tryUpdateBadgeAnchor();
if (badgeDrawable == null) {
throw new IllegalStateException("Unable to create badge");
}
return badgeDrawable;
}
@Nullable
private BadgeDrawable getBadge() {
return badgeDrawable;
}
private void removeBadge() {
if (badgeAnchorView != null) {
tryRemoveBadgeFromAnchor();
}
badgeDrawable = null;
}
private void addOnLayoutChangeListener(@Nullable final View view) {
if (view == null) {
return;
}
view.addOnLayoutChangeListener(
new OnLayoutChangeListener() {
@Override
public void onLayoutChange(
View v,
int left,
int top,
int right,
int bottom,
int oldLeft,
int oldTop,
int oldRight,
int oldBottom) {
if (view.getVisibility() == VISIBLE) {
tryUpdateBadgeDrawableBounds(view);
}
}
});
}
private void tryUpdateBadgeAnchor() {
if (!hasBadgeDrawable()) {
return;
}
if (customView != null) {
// TODO(b/123406505): Support badging on custom tab views.
tryRemoveBadgeFromAnchor();
} else {
if (iconView != null && tab != null && tab.getIcon() != null) {
if (badgeAnchorView != iconView) {
tryRemoveBadgeFromAnchor();
// Anchor badge to icon.
tryAttachBadgeToAnchor(iconView);
} else {
tryUpdateBadgeDrawableBounds(iconView);
}
} else if (textView != null
&& tab != null
&& tab.getTabLabelVisibility() == TAB_LABEL_VISIBILITY_LABELED) {
if (badgeAnchorView != textView) {
tryRemoveBadgeFromAnchor();
// Anchor badge to label.
tryAttachBadgeToAnchor(textView);
} else {
tryUpdateBadgeDrawableBounds(textView);
}
} else {
tryRemoveBadgeFromAnchor();
}
}
}
private void tryAttachBadgeToAnchor(@Nullable View anchorView) {
if (!hasBadgeDrawable()) {
return;
}
if (anchorView != null) {
clipViewToPaddingForBadge(false);
BadgeUtils.attachBadgeDrawable(
badgeDrawable, anchorView, getCustomParentForBadge(anchorView));
badgeAnchorView = anchorView;
}
}
private void tryRemoveBadgeFromAnchor() {
if (!hasBadgeDrawable()) {
return;
}
clipViewToPaddingForBadge(true);
if (badgeAnchorView != null) {
BadgeUtils.detachBadgeDrawable(badgeDrawable, badgeAnchorView);
badgeAnchorView = null;
}
}
private void clipViewToPaddingForBadge(boolean flag) {
// Avoid clipping a badge if it's displayed.
// Clip children / view to padding when no badge is displayed.
setClipChildren(flag);
setClipToPadding(flag);
ViewGroup parent = (ViewGroup) getParent();
if (parent != null) {
parent.setClipChildren(flag);
parent.setClipToPadding(flag);
}
}
final void updateOrientation() {
setOrientation(inlineLabel ? HORIZONTAL : VERTICAL);
if (customTextView != null || customIconView != null) {
updateTextAndIcon(customTextView, customIconView, /* addDefaultMargins= */ false);
} else {
updateTextAndIcon(textView, iconView, /* addDefaultMargins= */ true);
}
}
private void updateTextAndIcon(
@Nullable final TextView textView,
@Nullable final ImageView iconView,
final boolean addDefaultMargins) {
final Drawable icon =
(tab != null && tab.getIcon() != null)
? DrawableCompat.wrap(tab.getIcon()).mutate()
: null;
if (icon != null) {
DrawableCompat.setTintList(icon, tabIconTint);
if (tabIconTintMode != null) {
DrawableCompat.setTintMode(icon, tabIconTintMode);
}
}
final CharSequence text = tab != null ? tab.getText() : null;
if (iconView != null) {
if (icon != null) {
iconView.setImageDrawable(icon);
iconView.setVisibility(VISIBLE);
setVisibility(VISIBLE);
} else {
iconView.setVisibility(GONE);
iconView.setImageDrawable(null);
}
}
final boolean hasText = !TextUtils.isEmpty(text);
final boolean showingText;
if (textView != null) {
showingText = hasText && tab.labelVisibilityMode == TAB_LABEL_VISIBILITY_LABELED;
textView.setText(hasText ? text : null);
textView.setVisibility(showingText ? VISIBLE : GONE);
if (hasText) {
setVisibility(VISIBLE);
}
} else {
showingText = false;
}
if (addDefaultMargins && iconView != null) {
MarginLayoutParams lp = ((MarginLayoutParams) iconView.getLayoutParams());
int iconMargin = 0;
if (showingText && iconView.getVisibility() == VISIBLE) {
// If we're showing both text and icon, add some margin to the icon
iconMargin = (int) ViewUtils.dpToPx(getContext(), DEFAULT_GAP_TEXT_ICON);
}
if (inlineLabel) {
if (iconMargin != MarginLayoutParamsCompat.getMarginEnd(lp)) {
MarginLayoutParamsCompat.setMarginEnd(lp, iconMargin);
lp.bottomMargin = 0;
// Calls resolveLayoutParams(), necessary for layout direction
iconView.setLayoutParams(lp);
iconView.requestLayout();
}
} else {
if (iconMargin != lp.bottomMargin) {
lp.bottomMargin = iconMargin;
MarginLayoutParamsCompat.setMarginEnd(lp, 0);
// Calls resolveLayoutParams(), necessary for layout direction
iconView.setLayoutParams(lp);
iconView.requestLayout();
}
}
}
final CharSequence contentDesc = tab != null ? tab.contentDesc : null;
// Avoid calling tooltip for L and M devices because long pressing twice may freeze devices.
if (VERSION.SDK_INT < VERSION_CODES.LOLLIPOP || VERSION.SDK_INT > VERSION_CODES.M) {
TooltipCompat.setTooltipText(this, hasText ? text : contentDesc);
}
}
private void tryUpdateBadgeDrawableBounds(@NonNull View anchor) {
// Check that this view is the badge's current anchor view.
if (hasBadgeDrawable() && anchor == badgeAnchorView) {
BadgeUtils.setBadgeDrawableBounds(badgeDrawable, anchor, getCustomParentForBadge(anchor));
}
}
private boolean hasBadgeDrawable() {
return badgeDrawable != null;
}
@Nullable
private FrameLayout getCustomParentForBadge(@NonNull View anchor) {
if (anchor != iconView && anchor != textView) {
return null;
}
return BadgeUtils.USE_COMPAT_PARENT ? ((FrameLayout) anchor.getParent()) : null;
}
/**
* Calculates the width of the TabView's content.
*
* @return Width of the tab label, if present, or the width of the tab icon, if present. If tabs
* is in inline mode, returns the sum of both the icon and tab label widths.
*/
int getContentWidth() {
boolean initialized = false;
int left = 0;
int right = 0;
for (View view : new View[] {textView, iconView, customView}) {
if (view != null && view.getVisibility() == View.VISIBLE) {
left = initialized ? Math.min(left, view.getLeft()) : view.getLeft();
right = initialized ? Math.max(right, view.getRight()) : view.getRight();
initialized = true;
}
}
return right - left;
}
/**
* Calculates the height of the TabView's content.
*
* @return Height of the tab label, if present, or the height of the tab icon, if present. If
* the tab contains both a label and icon, the combined will be returned.
*/
int getContentHeight() {
boolean initialized = false;
int top = 0;
int bottom = 0;
for (View view : new View[] {textView, iconView, customView}) {
if (view != null && view.getVisibility() == View.VISIBLE) {
top = initialized ? Math.min(top, view.getTop()) : view.getTop();
bottom = initialized ? Math.max(bottom, view.getBottom()) : view.getBottom();
initialized = true;
}
}
return bottom - top;
}
@Nullable
public Tab getTab() {
return tab;
}
/** Approximates a given lines width with the new provided text size. */
private float approximateLineWidth(@NonNull Layout layout, int line, float textSize) {
return layout.getLineWidth(line) * (textSize / layout.getPaint().getTextSize());
}
}
class SlidingTabIndicator extends LinearLayout {
ValueAnimator indicatorAnimator;
private int layoutDirection = -1;
SlidingTabIndicator(Context context) {
super(context);
setWillNotDraw(false);
}
void setSelectedIndicatorHeight(int height) {
Rect bounds = tabSelectedIndicator.getBounds();
tabSelectedIndicator.setBounds(bounds.left, 0, bounds.right, height);
this.requestLayout();
}
boolean childrenNeedLayout() {
for (int i = 0, z = getChildCount(); i < z; i++) {
final View child = getChildAt(i);
if (child.getWidth() <= 0) {
return true;
}
}
return false;
}
/**
* Set the indicator position based on an offset between two adjacent tabs.
*
* @param position Position index of the first tab (with less index) currently being displayed.
* Tab position+1 will be visible if positionOffset is nonzero.
* @param positionOffset Value from [0, 1) indicating the offset from the tab at position.
*/
void setIndicatorPositionFromTabPosition(int position, float positionOffset) {
// Since we are tweening the indicator in between the position and position+positionOffset,
// we set the indicator position to whichever is closer.
indicatorPosition = Math.round(position + positionOffset);
if (indicatorAnimator != null && indicatorAnimator.isRunning()) {
indicatorAnimator.cancel();
}
// The title view refers to the one indicated when offset is 0.
final View firstTitle = getChildAt(position);
// The title view refers to the one indicated when offset is 1.
final View nextTitle = getChildAt(position + 1);
tweenIndicatorPosition(firstTitle, nextTitle, positionOffset);
}
@Override
public void onRtlPropertiesChanged(int layoutDirection) {
super.onRtlPropertiesChanged(layoutDirection);
// Workaround for a bug before Android M where LinearLayout did not re-layout itself when
// layout direction changed
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
if (this.layoutDirection != layoutDirection) {
requestLayout();
this.layoutDirection = layoutDirection;
}
}
}
@Override
protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY) {
// HorizontalScrollView will first measure use with UNSPECIFIED, and then with
// EXACTLY. Ignore the first call since anything we do will be overwritten anyway
return;
}
// GRAVITY_CENTER will make all tabs the same width as the largest tab, and center them in the
// SlidingTabIndicator's width (with a "gutter" of padding on either side). If the Tabs do not
// fit in the SlidingTabIndicator, then fall back to GRAVITY_FILL behavior.
if ((tabGravity == GRAVITY_CENTER) || mode == MODE_AUTO) {
final int count = getChildCount();
// First we'll find the widest tab
int largestTabWidth = 0;
for (int i = 0, z = count; i < z; i++) {
View child = getChildAt(i);
if (child.getVisibility() == VISIBLE) {
largestTabWidth = Math.max(largestTabWidth, child.getMeasuredWidth());
}
}
if (largestTabWidth <= 0) {
// If we don't have a largest child yet, skip until the next measure pass
return;
}
final int gutter = (int) ViewUtils.dpToPx(getContext(), FIXED_WRAP_GUTTER_MIN);
boolean remeasure = false;
if (largestTabWidth * count <= getMeasuredWidth() - gutter * 2) {
// If the tabs fit within our width minus gutters, we will set all tabs to have
// the same width
for (int i = 0; i < count; i++) {
final LinearLayout.LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams();
if (lp.width != largestTabWidth || lp.weight != 0) {
lp.width = largestTabWidth;
lp.weight = 0;
remeasure = true;
}
}
} else {
// If the tabs will wrap to be larger than the width minus gutters, we need
// to switch to GRAVITY_FILL.
// TODO (b/129799806): This overrides the user TabGravity setting.
tabGravity = GRAVITY_FILL;
updateTabViews(false);
remeasure = true;
}
if (remeasure) {
// Now re-measure after our changes
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (indicatorAnimator != null && indicatorAnimator.isRunning()) {
// It's possible that the tabs' layout is modified while the indicator is animating (ex. a
// new tab is added, or a tab is removed in onTabSelected). This would change the target end
// position of the indicator, since the tab widths are different. We need to modify the
// animation's updateListener to pick up the new target positions.
updateOrRecreateIndicatorAnimation(
/* recreateAnimation= */ false, getSelectedTabPosition(), /* duration= */ -1);
} else {
// If we've been laid out, update the indicator position
jumpIndicatorToIndicatorPosition();
}
}
/**
* Immediately update the indicator position to the specified position, unless we are mid-scroll
* in a viewpager.
*/
private void jumpIndicatorToPosition(int position) {
// Don't update the indicator position if the scroll state is not idle, and the indicator
// is drawn.
if (viewPagerScrollState != SCROLL_STATE_IDLE
&& !(getTabSelectedIndicator().getBounds().left == -1
&& getTabSelectedIndicator().getBounds().right == -1)) {
return;
}
final View currentView = getChildAt(position);
tabIndicatorInterpolator.setIndicatorBoundsForTab(
TabLayout.this, currentView, tabSelectedIndicator);
indicatorPosition = position;
}
/** Immediately update the indicator position to the currently selected position. */
private void jumpIndicatorToSelectedPosition() {
jumpIndicatorToPosition(getSelectedTabPosition());
}
/** Immediately update the indicator position to the current indicator position. */
private void jumpIndicatorToIndicatorPosition() {
// If indicator position has not yet been set, set indicator to the selected tab position.
if (indicatorPosition == -1) {
indicatorPosition = getSelectedTabPosition();
}
jumpIndicatorToPosition(indicatorPosition);
}
/**
* Update the position of the indicator by tweening between the currently selected tab and the
* destination tab.
*
*