Java tutorial
/* * Copyright (C) 2016 Ronald Martin <hello@itsronald.com> * * 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. * * Last modified 10/12/16 11:22 PM. */ package com.itsronald.widget; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.annotation.TargetApi; import android.content.Context; import android.content.res.TypedArray; import android.database.DataSetObserver; import android.graphics.Rect; import android.os.Build; import android.support.annotation.ColorInt; import android.support.annotation.Dimension; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.Px; import android.support.v4.view.PagerAdapter; import android.support.v4.view.ViewCompat; import android.support.v4.view.ViewPager; import android.util.AttributeSet; import android.util.Log; import android.view.Gravity; import android.view.ViewGroup; import android.view.ViewParent; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.List; /** * ViewPagerIndicator is a non-interactive indicator of the current, next, * and previous pages of a {@link ViewPager}. It is intended to be used as a * child view of a ViewPager widget in your XML layout. * * Add it as a child of a ViewPager in your layout file and set its * android:layout_gravity to TOP or BOTTOM to pin it to the top or bottom * of the ViewPager. */ @ViewPager.DecorView public class ViewPagerIndicator extends ViewGroup { @NonNull private static final String TAG = "ViewPagerIndicator"; private static final long DOT_SLIDE_ANIM_DURATION = 150; // 150 ms. //region ViewPager @NonNull private final PageListener pageListener = new PageListener(); @Nullable private ViewPager viewPager; @Nullable private WeakReference<PagerAdapter> pagerAdapterRef; //endregion //region Indicator Dots @Dimension static final int DEFAULT_DOT_PADDING_DIP = 9; @NonNull private final List<IndicatorDotView> indicatorDots = new ArrayList<>(); @NonNull private final List<IndicatorDotPathView> dotPaths = new ArrayList<>(); private IndicatorDotView selectedDot; // @NonNull, but initialized in init(). @Px private int dotPadding; @Px private int dotRadius; @ColorInt private int unselectedDotColor; @ColorInt private int selectedDotColor; //endregion //region State private int gravity = Gravity.CENTER_VERTICAL; private int lastKnownCurrentPage = -1; private float lastKnownPositionOffset = -1; private boolean isUpdatingPositions = false; private boolean isUpdatingIndicator = false; private boolean selectedDotNeedsLayout = true; //endregion //region Constructors public ViewPagerIndicator(Context context) { super(context); init(context, null, 0, 0); } public ViewPagerIndicator(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs, 0, 0); } public ViewPagerIndicator(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs, defStyleAttr, 0); } @TargetApi(Build.VERSION_CODES.LOLLIPOP) public ViewPagerIndicator(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); init(context, attrs, defStyleAttr, defStyleRes); } private void init(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { TypedArray attributes = context.obtainStyledAttributes(attrs, R.styleable.ViewPagerIndicator, defStyleAttr, defStyleRes); gravity = attributes.getInt(R.styleable.ViewPagerIndicator_android_gravity, gravity); final float scale = getResources().getDisplayMetrics().density; final int defaultDotPadding = (int) (DEFAULT_DOT_PADDING_DIP * scale + 0.5); dotPadding = attributes.getDimensionPixelSize(R.styleable.ViewPagerIndicator_dotPadding, defaultDotPadding); final int defaultDotRadius = (int) (IndicatorDotView.DEFAULT_DOT_RADIUS_DIP * scale + 0.5); dotRadius = attributes.getDimensionPixelSize(R.styleable.ViewPagerIndicator_dotRadius, defaultDotRadius); unselectedDotColor = attributes.getColor(R.styleable.ViewPagerIndicator_unselectedDotColor, IndicatorDotView.DEFAULT_UNSELECTED_DOT_COLOR); selectedDotColor = attributes.getColor(R.styleable.ViewPagerIndicator_selectedDotColor, IndicatorDotView.DEFAULT_SELECTED_DOT_COLOR); attributes.recycle(); selectedDot = new IndicatorDotView(context); selectedDot.setColor(selectedDotColor); selectedDot.setRadius(dotRadius); } //endregion @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { final int heightPadding = getPaddingTop() + getPaddingBottom(); final int childHeightSpec = getChildMeasureSpec(heightMeasureSpec, heightPadding, LayoutParams.WRAP_CONTENT); final int widthPadding = getPaddingLeft() + getPaddingRight(); final int childWidthSpec = getChildMeasureSpec(widthMeasureSpec, widthPadding, LayoutParams.WRAP_CONTENT); // Measure subviews. selectedDot.measure(childWidthSpec, childHeightSpec); for (IndicatorDotView indicatorDot : indicatorDots) { indicatorDot.measure(childWidthSpec, childHeightSpec); } for (IndicatorDotPathView dotPath : dotPaths) { dotPath.measure(childWidthSpec, childHeightSpec); } // Calculate measurement for this view. final int width; final int widthMode = MeasureSpec.getMode(widthMeasureSpec); if (widthMode == MeasureSpec.EXACTLY) { /* * Due to the implementation of onMeasure() in ViewPager, this case will always be * called if vertical layout_gravity is specified on this view. Since the Material * Design spec usually positions dot indicators like this at the bottom of pages, this * case will be called almost all the time. */ width = MeasureSpec.getSize(widthMeasureSpec); } else { final int dotCount = indicatorDots.size(); final int totalDotWidth = selectedDot.getMeasuredWidth() * dotCount; final int totalDotPadding = dotPadding * (dotCount - 1); final int minWidth = ViewCompat.getMinimumWidth(this); width = Math.max(minWidth, totalDotWidth + totalDotPadding + widthPadding); } final int height; final int heightMode = MeasureSpec.getMode(heightMeasureSpec); if (heightMode == MeasureSpec.EXACTLY) { height = MeasureSpec.getSize(heightMeasureSpec); } else { final int indicatorHeight = selectedDot.getMeasuredHeight(); final int minHeight = ViewCompat.getMinimumHeight(this); height = Math.max(minHeight, indicatorHeight + heightPadding); } final int childState = ViewCompat.getMeasuredHeightAndState(selectedDot); final int measuredHeight = ViewCompat.resolveSizeAndState(height, heightMeasureSpec, childState); setMeasuredDimension(width, measuredHeight); } @Override public void requestLayout() { if (!isUpdatingIndicator) { super.requestLayout(); } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { refresh(); } private void refresh() { if (viewPager != null) { updateIndicators(viewPager.getCurrentItem(), viewPager.getAdapter()); final float offset = lastKnownPositionOffset >= 0 ? lastKnownPositionOffset : 0; updateIndicatorPositions(lastKnownCurrentPage, offset, true); } } @Override protected void onAttachedToWindow() { // See: // https://android.googlesource.com/platform/frameworks/support/+/nougat-release/v4/java/android/support/v4/view/PagerTitleStrip.java#244 super.onAttachedToWindow(); final ViewParent parent = getParent(); if (!(parent instanceof ViewPager)) { throw new IllegalStateException("ViewPagerIndicator must be a direct child of a ViewPager."); } final ViewPager pager = (ViewPager) parent; viewPager = pager; final PagerAdapter adapter = pager.getAdapter(); pager.addOnPageChangeListener(pageListener); pager.addOnAdapterChangeListener(pageListener); final PagerAdapter lastAdapter = pagerAdapterRef != null ? pagerAdapterRef.get() : null; updateAdapter(lastAdapter, adapter); } @Override protected void onDetachedFromWindow() { // See: // https://android.googlesource.com/platform/frameworks/support/+/nougat-release/v4/java/android/support/v4/view/PagerTitleStrip.java#263 super.onDetachedFromWindow(); if (viewPager != null) { updateAdapter(viewPager.getAdapter(), null); viewPager.removeOnPageChangeListener(pageListener); viewPager.removeOnAdapterChangeListener(pageListener); viewPager = null; } } /** * Update the ViewPager adapter being observed by the indicator. The * <p> * Taken from: * https://android.googlesource.com/platform/frameworks/support/+/nougat-release/v4/java/android/support/v4/view/PagerTitleStrip.java#319 * * @param oldAdapter The previous adapter being tracked by the indicator. * @param newAdapter The previous adapter that should be tracked by the indicator. */ private void updateAdapter(@Nullable PagerAdapter oldAdapter, @Nullable PagerAdapter newAdapter) { if (oldAdapter != null) { oldAdapter.unregisterDataSetObserver(pageListener); pagerAdapterRef = null; } if (newAdapter != null) { newAdapter.registerDataSetObserver(pageListener); pagerAdapterRef = new WeakReference<>(newAdapter); } if (viewPager != null) { lastKnownCurrentPage = -1; lastKnownPositionOffset = -1; updateIndicators(viewPager.getCurrentItem(), newAdapter); requestLayout(); } } private void updateIndicators(int currentPage, @Nullable PagerAdapter pagerAdapter) { isUpdatingIndicator = true; final int pageCount = pagerAdapter == null ? 0 : pagerAdapter.getCount(); updateDotCount(pageCount); lastKnownCurrentPage = currentPage; if (!isUpdatingPositions) { updateIndicatorPositions(currentPage, lastKnownPositionOffset, false); } isUpdatingIndicator = false; } private void updateDotCount(int newDotCount) { final LayoutParams layoutParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); // Add unselected dots to layout. int dotCount = indicatorDots.size(); if (dotCount < newDotCount) { while (dotCount++ != newDotCount) { final IndicatorDotView newDot = new IndicatorDotView(getContext()); newDot.setRadius(dotRadius); newDot.setColor(unselectedDotColor); indicatorDots.add(newDot); addViewInLayout(newDot, -1, layoutParams, true); } } else if (dotCount > newDotCount) { final List<IndicatorDotView> removedDots = new ArrayList<>( indicatorDots.subList(newDotCount, dotCount)); for (IndicatorDotView removedDot : removedDots) { removeViewInLayout(removedDot); } indicatorDots.removeAll(removedDots); } // Make sure there is one fewer path than there are dots. updatePathCount(newDotCount - 1); // Add selected dot to layout. if (newDotCount > 0) { addViewInLayout(selectedDot, -1, layoutParams, true); } else { removeViewInLayout(selectedDot); } } private void updatePathCount(final int newPathCount) { int pathCount = dotPaths.size(); if (pathCount < newPathCount) { final LayoutParams layoutParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); while (pathCount++ != newPathCount) { final IndicatorDotPathView newPath = new IndicatorDotPathView(getContext(), getUnselectedDotColor(), getDotPadding(), getDotRadius()); newPath.setVisibility(INVISIBLE); dotPaths.add(newPath); addViewInLayout(newPath, -1, layoutParams, true); } } else if (pathCount > newPathCount && newPathCount >= 0) { final List<IndicatorDotPathView> pathsToRemove = new ArrayList<>( dotPaths.subList(newPathCount, pathCount)); for (IndicatorDotPathView dotPath : pathsToRemove) { removeViewInLayout(dotPath); } dotPaths.removeAll(pathsToRemove); } } /** * Taken from: * https://android.googlesource.com/platform/frameworks/support/+/nougat-release/v4/java/android/support/v4/view/PagerTitleStrip.java#336 * * @param currentPage The index of the page we are on in the ViewPager. * @param positionOffset The offset of the current page from horizontal center. * @param forceUpdate Whether or not to force an update */ private void updateIndicatorPositions(int currentPage, float positionOffset, boolean forceUpdate) { if (currentPage != lastKnownCurrentPage && viewPager != null) { updateIndicators(currentPage, viewPager.getAdapter()); } else if (!forceUpdate && positionOffset == lastKnownPositionOffset) { return; } isUpdatingPositions = true; final int dotWidth = 2 * dotRadius; final int top = calculateIndicatorDotTop(); final int bottom = top + dotWidth; int left = calculateIndicatorDotStart(); int right = left + dotWidth; for (int i = 0, dotCount = indicatorDots.size(), pathCount = dotPaths.size(); i < dotCount; ++i) { final IndicatorDotView dotView = indicatorDots.get(i); dotView.layout(left, top, right, bottom); if (i < pathCount) { final IndicatorDotPathView dotPath = dotPaths.get(i); dotPath.layout(left, top, left + dotPath.getMeasuredWidth(), bottom); } if (i == currentPage && selectedDotNeedsLayout) { selectedDot.layout(left, top, right, bottom); selectedDotNeedsLayout = false; } left = right + dotPadding; right = left + dotWidth; } selectedDot.bringToFront(); lastKnownPositionOffset = positionOffset; isUpdatingPositions = false; } /** * Calculate the starting vertical position for the line of indicator dots. * @return The first Y coordinate where the indicator dots start. */ @Px private int calculateIndicatorDotTop() { final int top; final int verticalGravity = gravity & Gravity.VERTICAL_GRAVITY_MASK; switch (verticalGravity) { default: case Gravity.CENTER_VERTICAL: top = (getHeight() - getPaddingTop() - getPaddingBottom()) / 2 - getDotRadius(); break; case Gravity.TOP: top = getPaddingTop(); break; case Gravity.BOTTOM: top = getHeight() - getPaddingBottom() - 2 * getDotRadius(); break; } return top; } /** * Calculate the starting horizontal position for the line of indicator dots. * Assumes dots are centered horizontally. * * @return The first X coordinate where the indicator dots start. */ @Px private int calculateIndicatorDotStart() { /* * Calculate the start position by starting from the center of the view and moving left * for half of the dots. */ final int dotCount = indicatorDots.size(); final float halfDotCount = dotCount / 2f; final int dotWidth = 2 * dotRadius; final float totalDotWidth = dotWidth * halfDotCount; // # dot gaps = (numDots - 1), so # dot gaps / 2 = (numDots - 1) / 2 = halfDotCount - 0.5. final float halfDotPaddingCount = Math.max(halfDotCount - 0.5f, 0); final float totalDotPaddingWidth = dotPadding * halfDotPaddingCount; int startPosition = getWidth() / 2; startPosition -= totalDotWidth + totalDotPaddingWidth; return startPosition; } @Nullable private Animator pageChangeAnimator(final int lastPageIndex, final int newPageIndex) { final IndicatorDotPathView dotPath = getDotPathForPageChange(lastPageIndex, newPageIndex); final IndicatorDotView lastDot = getDotForPage(lastPageIndex); if (dotPath == null || lastDot == null) { final String warning = dotPath == null ? "dotPath is null!" : "lastDot is null!"; Log.w(TAG, warning); return null; } final Animator connectPathAnimator = dotPath.connectPathAnimator(); connectPathAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { dotPath.setVisibility(VISIBLE); lastDot.setVisibility(INVISIBLE); } }); final long dotSlideDuration = DOT_SLIDE_ANIM_DURATION; final Animator selectedDotSlideAnimator = selectedDotSlideAnimator(newPageIndex, dotSlideDuration, 0); final int pathDirection = getPathDirectionForPageChange(lastPageIndex, newPageIndex); final Animator retreatPathAnimator = dotPath.retreatConnectedPathAnimator(pathDirection); final Animator dotRevealAnimator = lastDot.revealAnimator(); dotRevealAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { dotPath.setVisibility(INVISIBLE); } }); final AnimatorSet animatorSet = new AnimatorSet(); animatorSet.play(connectPathAnimator).before(selectedDotSlideAnimator); animatorSet.play(retreatPathAnimator).after(selectedDotSlideAnimator); animatorSet.play(dotRevealAnimator).with(retreatPathAnimator); return animatorSet; } @NonNull private Animator selectedDotSlideAnimator(int newPageIndex, long animationDuration, long startDelay) { final Rect newPageDotRect = new Rect(); final IndicatorDotView newPageDot = getDotForPage(newPageIndex); if (newPageDot != null) { newPageDot.getDrawingRect(newPageDotRect); offsetDescendantRectToMyCoords(newPageDot, newPageDotRect); offsetRectIntoDescendantCoords(selectedDot, newPageDotRect); } final float toX = newPageDotRect.left; final float toY = newPageDotRect.top; final Animator animator = selectedDot.slideAnimator(toX, toY, animationDuration); animator.setStartDelay(startDelay); return animator; } /** * Watches the ViewPager for changes, updating the indicator as needed. */ private class PageListener extends DataSetObserver implements ViewPager.OnPageChangeListener, ViewPager.OnAdapterChangeListener { private int scrollState; @Override public void onChanged() { super.onChanged(); refresh(); } //region ViewPager.OnPageChangeListener @Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { //do nothing } @Override public void onPageSelected(int position) { final Animator pageChangeAnimator = pageChangeAnimator(lastKnownCurrentPage, position); if (scrollState == ViewPager.SCROLL_STATE_IDLE && viewPager != null) { // Only update the text here if we're not dragging or settling. refresh(); } if (pageChangeAnimator != null) { pageChangeAnimator.start(); } //update lastKnownCurrentPage here lastKnownCurrentPage = position; } @Override public void onPageScrollStateChanged(int state) { scrollState = state; } //endregion //region ViewPager.OnAdapterChangeListener @Override public void onAdapterChanged(@NonNull ViewPager viewPager, @Nullable PagerAdapter oldAdapter, @Nullable PagerAdapter newAdapter) { updateAdapter(oldAdapter, newAdapter); } //endregion } //region Accessors @Nullable private IndicatorDotView getDotForPage(int pageIndex) { if (pageIndex > indicatorDots.size() - 1 || pageIndex < 0) return null; return indicatorDots.get(pageIndex); } @Nullable private IndicatorDotPathView getDotPathForPageChange(int oldPageIndex, int newPageIndex) { if (oldPageIndex < 0 || newPageIndex < 0 || oldPageIndex == newPageIndex) return null; final int dotPathIndex = oldPageIndex < newPageIndex ? oldPageIndex : newPageIndex; return dotPathIndex >= dotPaths.size() ? null : dotPaths.get(dotPathIndex); } @IndicatorDotPathView.PathDirection private int getPathDirectionForPageChange(int oldPageIndex, int newPageIndex) { return oldPageIndex < newPageIndex ? IndicatorDotPathView.PATH_DIRECTION_RIGHT : IndicatorDotPathView.PATH_DIRECTION_LEFT; } /** * Get the {@link Gravity} used to position dots within the indicator. * Only the vertical gravity component is used. */ public int getGravity() { return gravity; } /** * Set the {@link Gravity} used to position dots within the indicator. * Only the vertical gravity component is used. * * @param newGravity {@link Gravity} constant for positioning indicator dots. */ public void setGravity(int newGravity) { gravity = newGravity; requestLayout(); } /** * Get the current spacing between each indicator dot. * * @return The distance between each indicator dot, in pixels. */ @Px public int getDotPadding() { return dotPadding; } /** * Set the spacing between each indicator dot. * * @param newDotPadding The distance to use between each indicator dot, in pixels. */ public void setDotPadding(@Px int newDotPadding) { if (dotPadding == newDotPadding) return; if (newDotPadding < 0) newDotPadding = 0; dotPadding = newDotPadding; invalidate(); requestLayout(); } /** * Get the current radius of each indicator dot. * * @return The radius of each indicator dot, in pixels. */ @Px public int getDotRadius() { return dotRadius; } /** * Set the radius of each indicator dot. * * @param newRadius The new radius to use for each indicator dot. */ public void setDotRadius(@Px int newRadius) { if (dotRadius == newRadius) return; if (newRadius < 0) newRadius = 0; dotRadius = newRadius; for (IndicatorDotView indicatorDot : indicatorDots) { indicatorDot.setRadius(dotRadius); } invalidate(); requestLayout(); } /** * Get the current color for unselected indicator dots. * * @return The unselected dot color. */ @ColorInt public int getUnselectedDotColor() { return unselectedDotColor; } /** * Set the current color for unselected indicator dots. * * @param color The new unselected dot color to use. */ public void setUnselectedDotColor(@ColorInt int color) { unselectedDotColor = color; for (IndicatorDotView indicatordot : indicatorDots) { indicatordot.setColor(color); indicatordot.invalidate(); } } /** * Get the current color for selected indicator dots. * * @return The selected dot color. */ @ColorInt public int getSelectedDotColor() { return selectedDotColor; } /** * Set the current color for selected indicator dots. * * @param color The new selected dot color to use. */ public void setSelectedDotColor(@ColorInt int color) { selectedDotColor = color; if (selectedDot != null) { selectedDot.setColor(color); selectedDot.invalidate(); } } //endregion }