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