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