1 /* 2 * Copyright 2018 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.viewpager.widget; 18 19 import android.content.Context; 20 import android.content.res.TypedArray; 21 import android.database.DataSetObserver; 22 import android.graphics.drawable.Drawable; 23 import android.text.TextUtils.TruncateAt; 24 import android.text.method.SingleLineTransformationMethod; 25 import android.util.AttributeSet; 26 import android.util.TypedValue; 27 import android.view.Gravity; 28 import android.view.View; 29 import android.view.ViewGroup; 30 import android.view.ViewParent; 31 import android.widget.TextView; 32 33 import androidx.annotation.ColorInt; 34 import androidx.annotation.FloatRange; 35 import androidx.annotation.NonNull; 36 import androidx.annotation.Nullable; 37 import androidx.core.widget.TextViewCompat; 38 39 import java.lang.ref.WeakReference; 40 import java.util.Locale; 41 42 /** 43 * PagerTitleStrip is a non-interactive indicator of the current, next, 44 * and previous pages of a {@link ViewPager}. It is intended to be used as a 45 * child view of a ViewPager widget in your XML layout. 46 * Add it as a child of a ViewPager in your layout file and set its 47 * android:layout_gravity to TOP or BOTTOM to pin it to the top or bottom 48 * of the ViewPager. The title from each page is supplied by the method 49 * {@link PagerAdapter#getPageTitle(int)} in the adapter supplied to 50 * the ViewPager. 51 * 52 * <p>For an interactive indicator, see {@link PagerTabStrip}.</p> 53 */ 54 @ViewPager.DecorView 55 public class PagerTitleStrip extends ViewGroup { 56 ViewPager mPager; 57 TextView mPrevText; 58 TextView mCurrText; 59 TextView mNextText; 60 61 private int mLastKnownCurrentPage = -1; 62 float mLastKnownPositionOffset = -1; 63 private int mScaledTextSpacing; 64 private int mGravity; 65 66 private boolean mUpdatingText; 67 private boolean mUpdatingPositions; 68 69 private final PageListener mPageListener = new PageListener(); 70 71 private WeakReference<PagerAdapter> mWatchingAdapter; 72 73 private static final int[] ATTRS = new int[] { 74 android.R.attr.textAppearance, 75 android.R.attr.textSize, 76 android.R.attr.textColor, 77 android.R.attr.gravity 78 }; 79 80 private static final int[] TEXT_ATTRS = new int[] { 81 0x0101038c // android.R.attr.textAllCaps 82 }; 83 84 private static final float SIDE_ALPHA = 0.6f; 85 private static final int TEXT_SPACING = 16; // dip 86 87 private int mNonPrimaryAlpha; 88 int mTextColor; 89 90 private static class SingleLineAllCapsTransform extends SingleLineTransformationMethod { 91 private Locale mLocale; 92 SingleLineAllCapsTransform(Context context)93 SingleLineAllCapsTransform(Context context) { 94 mLocale = context.getResources().getConfiguration().locale; 95 } 96 97 @Override getTransformation(CharSequence source, View view)98 public CharSequence getTransformation(CharSequence source, View view) { 99 source = super.getTransformation(source, view); 100 return source != null ? source.toString().toUpperCase(mLocale) : null; 101 } 102 } 103 setSingleLineAllCaps(TextView text)104 private static void setSingleLineAllCaps(TextView text) { 105 text.setTransformationMethod(new SingleLineAllCapsTransform(text.getContext())); 106 } 107 PagerTitleStrip(@onNull Context context)108 public PagerTitleStrip(@NonNull Context context) { 109 this(context, null); 110 } 111 PagerTitleStrip(@onNull Context context, @Nullable AttributeSet attrs)112 public PagerTitleStrip(@NonNull Context context, @Nullable AttributeSet attrs) { 113 super(context, attrs); 114 115 addView(mPrevText = new TextView(context)); 116 addView(mCurrText = new TextView(context)); 117 addView(mNextText = new TextView(context)); 118 119 final TypedArray a = context.obtainStyledAttributes(attrs, ATTRS); 120 final int textAppearance = a.getResourceId(0, 0); 121 if (textAppearance != 0) { 122 TextViewCompat.setTextAppearance(mPrevText, textAppearance); 123 TextViewCompat.setTextAppearance(mCurrText, textAppearance); 124 TextViewCompat.setTextAppearance(mNextText, textAppearance); 125 } 126 final int textSize = a.getDimensionPixelSize(1, 0); 127 if (textSize != 0) { 128 setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize); 129 } 130 if (a.hasValue(2)) { 131 final int textColor = a.getColor(2, 0); 132 mPrevText.setTextColor(textColor); 133 mCurrText.setTextColor(textColor); 134 mNextText.setTextColor(textColor); 135 } 136 mGravity = a.getInteger(3, Gravity.BOTTOM); 137 a.recycle(); 138 139 mTextColor = mCurrText.getTextColors().getDefaultColor(); 140 setNonPrimaryAlpha(SIDE_ALPHA); 141 142 mPrevText.setEllipsize(TruncateAt.END); 143 mCurrText.setEllipsize(TruncateAt.END); 144 mNextText.setEllipsize(TruncateAt.END); 145 146 boolean allCaps = false; 147 if (textAppearance != 0) { 148 final TypedArray ta = context.obtainStyledAttributes(textAppearance, TEXT_ATTRS); 149 allCaps = ta.getBoolean(0, false); 150 ta.recycle(); 151 } 152 153 if (allCaps) { 154 setSingleLineAllCaps(mPrevText); 155 setSingleLineAllCaps(mCurrText); 156 setSingleLineAllCaps(mNextText); 157 } else { 158 mPrevText.setSingleLine(); 159 mCurrText.setSingleLine(); 160 mNextText.setSingleLine(); 161 } 162 163 final float density = context.getResources().getDisplayMetrics().density; 164 mScaledTextSpacing = (int) (TEXT_SPACING * density); 165 } 166 167 /** 168 * Set the required spacing between title segments. 169 * 170 * @param spacingPixels Spacing between each title displayed in pixels 171 */ setTextSpacing(int spacingPixels)172 public void setTextSpacing(int spacingPixels) { 173 mScaledTextSpacing = spacingPixels; 174 requestLayout(); 175 } 176 177 /** 178 * @return The required spacing between title segments in pixels 179 */ getTextSpacing()180 public int getTextSpacing() { 181 return mScaledTextSpacing; 182 } 183 184 /** 185 * Set the alpha value used for non-primary page titles. 186 * 187 * @param alpha Opacity value in the range 0-1f 188 */ setNonPrimaryAlpha(@loatRangefrom = 0.0, to = 1.0) float alpha)189 public void setNonPrimaryAlpha(@FloatRange(from = 0.0, to = 1.0) float alpha) { 190 mNonPrimaryAlpha = (int) (alpha * 255) & 0xFF; 191 final int transparentColor = (mNonPrimaryAlpha << 24) | (mTextColor & 0xFFFFFF); 192 mPrevText.setTextColor(transparentColor); 193 mNextText.setTextColor(transparentColor); 194 } 195 196 /** 197 * Set the color value used as the base color for all displayed page titles. 198 * Alpha will be ignored for non-primary page titles. See {@link #setNonPrimaryAlpha(float)}. 199 * 200 * @param color Color hex code in 0xAARRGGBB format 201 */ setTextColor(@olorInt int color)202 public void setTextColor(@ColorInt int color) { 203 mTextColor = color; 204 mCurrText.setTextColor(color); 205 final int transparentColor = (mNonPrimaryAlpha << 24) | (mTextColor & 0xFFFFFF); 206 mPrevText.setTextColor(transparentColor); 207 mNextText.setTextColor(transparentColor); 208 } 209 210 /** 211 * Set the default text size to a given unit and value. 212 * See {@link TypedValue} for the possible dimension units. 213 * 214 * <p>Example: to set the text size to 14px, use 215 * setTextSize(TypedValue.COMPLEX_UNIT_PX, 14);</p> 216 * 217 * @param unit The desired dimension unit 218 * @param size The desired size in the given units 219 */ setTextSize(int unit, float size)220 public void setTextSize(int unit, float size) { 221 mPrevText.setTextSize(unit, size); 222 mCurrText.setTextSize(unit, size); 223 mNextText.setTextSize(unit, size); 224 } 225 226 /** 227 * Set the {@link Gravity} used to position text within the title strip. 228 * Only the vertical gravity component is used. 229 * 230 * @param gravity {@link Gravity} constant for positioning title text 231 */ setGravity(int gravity)232 public void setGravity(int gravity) { 233 mGravity = gravity; 234 requestLayout(); 235 } 236 237 @Override onAttachedToWindow()238 protected void onAttachedToWindow() { 239 super.onAttachedToWindow(); 240 241 final ViewParent parent = getParent(); 242 if (!(parent instanceof ViewPager)) { 243 throw new IllegalStateException( 244 "PagerTitleStrip must be a direct child of a ViewPager."); 245 } 246 247 final ViewPager pager = (ViewPager) parent; 248 final PagerAdapter adapter = pager.getAdapter(); 249 250 pager.setInternalPageChangeListener(mPageListener); 251 pager.addOnAdapterChangeListener(mPageListener); 252 mPager = pager; 253 updateAdapter(mWatchingAdapter != null ? mWatchingAdapter.get() : null, adapter); 254 } 255 256 @Override onDetachedFromWindow()257 protected void onDetachedFromWindow() { 258 super.onDetachedFromWindow(); 259 if (mPager != null) { 260 updateAdapter(mPager.getAdapter(), null); 261 mPager.setInternalPageChangeListener(null); 262 mPager.removeOnAdapterChangeListener(mPageListener); 263 mPager = null; 264 } 265 } 266 updateText(int currentItem, PagerAdapter adapter)267 void updateText(int currentItem, PagerAdapter adapter) { 268 final int itemCount = adapter != null ? adapter.getCount() : 0; 269 mUpdatingText = true; 270 271 CharSequence text = null; 272 if (currentItem >= 1 && adapter != null) { 273 text = adapter.getPageTitle(currentItem - 1); 274 } 275 mPrevText.setText(text); 276 277 mCurrText.setText(adapter != null && currentItem < itemCount 278 ? adapter.getPageTitle(currentItem) : null); 279 280 text = null; 281 if (currentItem + 1 < itemCount && adapter != null) { 282 text = adapter.getPageTitle(currentItem + 1); 283 } 284 mNextText.setText(text); 285 286 // Measure everything 287 final int width = getWidth() - getPaddingLeft() - getPaddingRight(); 288 final int maxWidth = Math.max(0, (int) (width * 0.8f)); 289 final int childWidthSpec = MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST); 290 final int childHeight = getHeight() - getPaddingTop() - getPaddingBottom(); 291 final int maxHeight = Math.max(0, childHeight); 292 final int childHeightSpec = MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST); 293 mPrevText.measure(childWidthSpec, childHeightSpec); 294 mCurrText.measure(childWidthSpec, childHeightSpec); 295 mNextText.measure(childWidthSpec, childHeightSpec); 296 297 mLastKnownCurrentPage = currentItem; 298 299 if (!mUpdatingPositions) { 300 updateTextPositions(currentItem, mLastKnownPositionOffset, false); 301 } 302 303 mUpdatingText = false; 304 } 305 306 @Override requestLayout()307 public void requestLayout() { 308 if (!mUpdatingText) { 309 super.requestLayout(); 310 } 311 } 312 updateAdapter(PagerAdapter oldAdapter, PagerAdapter newAdapter)313 void updateAdapter(PagerAdapter oldAdapter, PagerAdapter newAdapter) { 314 if (oldAdapter != null) { 315 oldAdapter.unregisterDataSetObserver(mPageListener); 316 mWatchingAdapter = null; 317 } 318 if (newAdapter != null) { 319 newAdapter.registerDataSetObserver(mPageListener); 320 mWatchingAdapter = new WeakReference<PagerAdapter>(newAdapter); 321 } 322 if (mPager != null) { 323 mLastKnownCurrentPage = -1; 324 mLastKnownPositionOffset = -1; 325 updateText(mPager.getCurrentItem(), newAdapter); 326 requestLayout(); 327 } 328 } 329 updateTextPositions(int position, float positionOffset, boolean force)330 void updateTextPositions(int position, float positionOffset, boolean force) { 331 if (position != mLastKnownCurrentPage) { 332 updateText(position, mPager.getAdapter()); 333 } else if (!force && positionOffset == mLastKnownPositionOffset) { 334 return; 335 } 336 337 mUpdatingPositions = true; 338 339 final int prevWidth = mPrevText.getMeasuredWidth(); 340 final int currWidth = mCurrText.getMeasuredWidth(); 341 final int nextWidth = mNextText.getMeasuredWidth(); 342 final int halfCurrWidth = currWidth / 2; 343 344 final int stripWidth = getWidth(); 345 final int stripHeight = getHeight(); 346 final int paddingLeft = getPaddingLeft(); 347 final int paddingRight = getPaddingRight(); 348 final int paddingTop = getPaddingTop(); 349 final int paddingBottom = getPaddingBottom(); 350 final int textPaddedLeft = paddingLeft + halfCurrWidth; 351 final int textPaddedRight = paddingRight + halfCurrWidth; 352 final int contentWidth = stripWidth - textPaddedLeft - textPaddedRight; 353 354 float currOffset = positionOffset + 0.5f; 355 if (currOffset > 1.f) { 356 currOffset -= 1.f; 357 } 358 final int currCenter = stripWidth - textPaddedRight - (int) (contentWidth * currOffset); 359 final int currLeft = currCenter - currWidth / 2; 360 final int currRight = currLeft + currWidth; 361 362 final int prevBaseline = mPrevText.getBaseline(); 363 final int currBaseline = mCurrText.getBaseline(); 364 final int nextBaseline = mNextText.getBaseline(); 365 final int maxBaseline = Math.max(Math.max(prevBaseline, currBaseline), nextBaseline); 366 final int prevTopOffset = maxBaseline - prevBaseline; 367 final int currTopOffset = maxBaseline - currBaseline; 368 final int nextTopOffset = maxBaseline - nextBaseline; 369 final int alignedPrevHeight = prevTopOffset + mPrevText.getMeasuredHeight(); 370 final int alignedCurrHeight = currTopOffset + mCurrText.getMeasuredHeight(); 371 final int alignedNextHeight = nextTopOffset + mNextText.getMeasuredHeight(); 372 final int maxTextHeight = Math.max(Math.max(alignedPrevHeight, alignedCurrHeight), 373 alignedNextHeight); 374 375 final int vgrav = mGravity & Gravity.VERTICAL_GRAVITY_MASK; 376 377 int prevTop; 378 int currTop; 379 int nextTop; 380 switch (vgrav) { 381 default: 382 case Gravity.TOP: 383 prevTop = paddingTop + prevTopOffset; 384 currTop = paddingTop + currTopOffset; 385 nextTop = paddingTop + nextTopOffset; 386 break; 387 case Gravity.CENTER_VERTICAL: 388 final int paddedHeight = stripHeight - paddingTop - paddingBottom; 389 final int centeredTop = (paddedHeight - maxTextHeight) / 2; 390 prevTop = centeredTop + prevTopOffset; 391 currTop = centeredTop + currTopOffset; 392 nextTop = centeredTop + nextTopOffset; 393 break; 394 case Gravity.BOTTOM: 395 final int bottomGravTop = stripHeight - paddingBottom - maxTextHeight; 396 prevTop = bottomGravTop + prevTopOffset; 397 currTop = bottomGravTop + currTopOffset; 398 nextTop = bottomGravTop + nextTopOffset; 399 break; 400 } 401 402 mCurrText.layout(currLeft, currTop, currRight, 403 currTop + mCurrText.getMeasuredHeight()); 404 405 final int prevLeft = Math.min(paddingLeft, currLeft - mScaledTextSpacing - prevWidth); 406 mPrevText.layout(prevLeft, prevTop, prevLeft + prevWidth, 407 prevTop + mPrevText.getMeasuredHeight()); 408 409 final int nextLeft = Math.max(stripWidth - paddingRight - nextWidth, 410 currRight + mScaledTextSpacing); 411 mNextText.layout(nextLeft, nextTop, nextLeft + nextWidth, 412 nextTop + mNextText.getMeasuredHeight()); 413 414 mLastKnownPositionOffset = positionOffset; 415 mUpdatingPositions = false; 416 } 417 418 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)419 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 420 final int widthMode = MeasureSpec.getMode(widthMeasureSpec); 421 if (widthMode != MeasureSpec.EXACTLY) { 422 throw new IllegalStateException("Must measure with an exact width"); 423 } 424 425 final int heightPadding = getPaddingTop() + getPaddingBottom(); 426 final int childHeightSpec = getChildMeasureSpec(heightMeasureSpec, 427 heightPadding, LayoutParams.WRAP_CONTENT); 428 429 final int widthSize = MeasureSpec.getSize(widthMeasureSpec); 430 final int widthPadding = (int) (widthSize * 0.2f); 431 final int childWidthSpec = getChildMeasureSpec(widthMeasureSpec, 432 widthPadding, LayoutParams.WRAP_CONTENT); 433 434 mPrevText.measure(childWidthSpec, childHeightSpec); 435 mCurrText.measure(childWidthSpec, childHeightSpec); 436 mNextText.measure(childWidthSpec, childHeightSpec); 437 438 final int height; 439 final int heightMode = MeasureSpec.getMode(heightMeasureSpec); 440 if (heightMode == MeasureSpec.EXACTLY) { 441 height = MeasureSpec.getSize(heightMeasureSpec); 442 } else { 443 final int textHeight = mCurrText.getMeasuredHeight(); 444 final int minHeight = getMinHeight(); 445 height = Math.max(minHeight, textHeight + heightPadding); 446 } 447 448 final int childState = mCurrText.getMeasuredState(); 449 final int measuredHeight = View.resolveSizeAndState(height, heightMeasureSpec, 450 childState << View.MEASURED_HEIGHT_STATE_SHIFT); 451 setMeasuredDimension(widthSize, measuredHeight); 452 } 453 454 @Override onLayout(boolean changed, int l, int t, int r, int b)455 protected void onLayout(boolean changed, int l, int t, int r, int b) { 456 if (mPager != null) { 457 final float offset = mLastKnownPositionOffset >= 0 ? mLastKnownPositionOffset : 0; 458 updateTextPositions(mLastKnownCurrentPage, offset, true); 459 } 460 } 461 getMinHeight()462 int getMinHeight() { 463 int minHeight = 0; 464 final Drawable bg = getBackground(); 465 if (bg != null) { 466 minHeight = bg.getIntrinsicHeight(); 467 } 468 return minHeight; 469 } 470 471 private class PageListener extends DataSetObserver implements ViewPager.OnPageChangeListener, 472 ViewPager.OnAdapterChangeListener { 473 private int mScrollState; 474 PageListener()475 PageListener() { 476 } 477 478 @Override onPageScrolled(int position, float positionOffset, int positionOffsetPixels)479 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { 480 if (positionOffset > 0.5f) { 481 // Consider ourselves to be on the next page when we're 50% of the way there. 482 position++; 483 } 484 updateTextPositions(position, positionOffset, false); 485 } 486 487 @Override onPageSelected(int position)488 public void onPageSelected(int position) { 489 if (mScrollState == ViewPager.SCROLL_STATE_IDLE) { 490 // Only update the text here if we're not dragging or settling. 491 updateText(mPager.getCurrentItem(), mPager.getAdapter()); 492 493 final float offset = mLastKnownPositionOffset >= 0 ? mLastKnownPositionOffset : 0; 494 updateTextPositions(mPager.getCurrentItem(), offset, true); 495 } 496 } 497 498 @Override onPageScrollStateChanged(int state)499 public void onPageScrollStateChanged(int state) { 500 mScrollState = state; 501 } 502 503 @Override onAdapterChanged(ViewPager viewPager, PagerAdapter oldAdapter, PagerAdapter newAdapter)504 public void onAdapterChanged(ViewPager viewPager, PagerAdapter oldAdapter, 505 PagerAdapter newAdapter) { 506 updateAdapter(oldAdapter, newAdapter); 507 } 508 509 @Override onChanged()510 public void onChanged() { 511 updateText(mPager.getCurrentItem(), mPager.getAdapter()); 512 513 final float offset = mLastKnownPositionOffset >= 0 ? mLastKnownPositionOffset : 0; 514 updateTextPositions(mPager.getCurrentItem(), offset, true); 515 } 516 } 517 } 518