/* * Copyright 2016 Philip Cohn-Cort * * 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.fuzz.indicator; import android.annotation.TargetApi; import android.content.Context; import android.content.res.TypedArray; import android.database.Cursor; import android.database.DataSetObserver; import android.os.Build; import android.support.annotation.DrawableRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.view.ViewPager; import android.util.AttributeSet; import android.util.Log; import android.util.SparseArray; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import com.fuzz.indicator.cell.CutoutCell; import com.fuzz.indicator.cell.CutoutTextCell; import com.fuzz.indicator.proxy.IndicatorOffsetEvent; import com.fuzz.indicator.proxy.ProxyReference; import com.fuzz.indicator.proxy.StateProxy; import com.fuzz.indicator.proxy.UnavailableProxy; import com.fuzz.indicator.proxy.ViewProvidingStateProxy; import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; /** * A RecyclerView-inspired ViewGroup for showing an indicator traversing multiple child Views ('cells'). *

* There's a nice monospace line drawing in the javadoc for {@link #showOffsetIndicator(int, float)} that basically sums up * its appearance when operating under an {@link ImageCellGenerator}. *

* * @author Philip Cohn-Cort (Fuzz) */ public class CutoutViewIndicator extends LinearLayout implements ProxyReference { private static final String TAG = CutoutViewIndicator.class.getSimpleName(); /** * Specialised implementation of {@link UnavailableProxy} for use with * {@linkplain #isInEditMode() the preview tools}. */ protected static final UnavailableProxy EDIT_MODE_PROXY = new UnavailableProxy() { @Override public IndicatorOffsetEvent resendPositionInfo(ProxyReference cvi, float pos) { return IndicatorOffsetEvent.from(cvi, pos); } }; /** * This holds onto the views that may be attached to this ViewGroup. It's initialised * with space for 5 values because practical experience says that space for 10 would * be excessive. *

* This is kinda micro-optimising since it can expand automatically later. *

*/ @NonNull protected SparseArray cells = new SparseArray<>(5); /** * Default configuration used as a base for {@link #generateDefaultLayoutParams()} * and {@link #bindChild(int, View)} */ @NonNull protected CutoutViewLayoutParams defaultChildParams; /** * A wrapper for the current state - see {@link StateProxy} javadoc for details. * * @see #setStateProxy(StateProxy) * @see #setViewPager(ViewPager) */ @NonNull protected StateProxy stateProxy = new UnavailableProxy(); /** * @see #cascadeParamChanges(boolean) */ protected boolean cascadeChanges; /** * {@code com.eccyan.widget.SpinningViewPager} reports positions as one greater than other ViewPagers. When this variable * is true, CutoutViewIndicator will correct for the discrepancy. */ protected boolean usePositiveOffset; /** * This value is strictly for showing a good tools-style preview of this view. If * {@link #isInEditMode()} is false, this value should never be used. */ private int editModePageCount; /** * This object is responsible for creating new cells * @see #showOffsetIndicator(int, float) */ @NonNull protected CutoutCellGenerator generator; /** * This value is set by {@link #showOffsetIndicator(int, IndicatorOffsetEvent)} into * {@link IndicatorOffsetEvent#setOffSetHints(int)}. */ @OffSetHint protected int offsetHints = OffSetHint.IMAGE_TRANSLATE; @NonNull protected LayoutLogger.Logger logger; /** * An observer which gets notified whenever the data backing {@link #stateProxy} changes. * * By default, this is an implementation of {@link IntegratedDataSetObserver}. */ protected DataSetObserver dataSetObserver = new IntegratedDataSetObserver(); /** *

* Association and disassociation of the {@link #dataSetObserver observer} usually takes place * in {@link #setStateProxy(StateProxy)}. Old proxies receive a call to * {@link StateProxy#disassociateFrom(DataSetObserver)}, while new ones receive a call to * {@link StateProxy#associateWith(DataSetObserver)}. *

*

* This field stores the value of the most recent call to * {@link StateProxy#canObserve(DataSetObserver)}, so that {@link StateProxy proxies} which * weren't associated in the first place aren't asked to disassociate. *

*/ protected boolean currentProxyMayDisassociate; public CutoutViewIndicator(Context context) { this(context, null); } public CutoutViewIndicator(Context context, AttributeSet attrs) { this(context, attrs, 0); } public CutoutViewIndicator(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); logger = LayoutLogger.getPreferred(isInEditMode()); generator = new ImageCellGenerator(); defaultChildParams = new CutoutViewLayoutParams(WRAP_CONTENT, WRAP_CONTENT); init(context, attrs, defStyleAttr); } @TargetApi(Build.VERSION_CODES.LOLLIPOP) public CutoutViewIndicator(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); logger = LayoutLogger.getPreferred(isInEditMode()); generator = new ImageCellGenerator(); defaultChildParams = new CutoutViewLayoutParams(WRAP_CONTENT, WRAP_CONTENT); init(context, attrs, defStyleAttr); } @SuppressWarnings("ResourceType") protected void init(Context context, AttributeSet attrs, int defStyleAttr) { if (attrs != null) { TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CutoutViewIndicator); setIndicatorDrawableId(a.getResourceId(R.styleable.CutoutViewIndicator_rcv_drawable, 0)); setInternalSpacing(a.getDimensionPixelOffset(R.styleable.CutoutViewIndicator_rcv_internal_margin, 0)); // The superclass will have resolved orientation by now. if (getOrientation() == HORIZONTAL) { setPerpendicularLength(a.getDimensionPixelSize(R.styleable.CutoutViewIndicator_rcv_height, WRAP_CONTENT)); setCellLength(a.getDimensionPixelOffset(R.styleable.CutoutViewIndicator_rcv_width, WRAP_CONTENT)); } else { setPerpendicularLength(a.getDimensionPixelSize(R.styleable.CutoutViewIndicator_rcv_width, WRAP_CONTENT)); setCellLength(a.getDimensionPixelOffset(R.styleable.CutoutViewIndicator_rcv_height, WRAP_CONTENT)); } if (isInEditMode()) { editModePageCount = a.getInteger(R.styleable.CutoutViewIndicator_rcv_tools_indicator_count, 2); } String generatorName = a.getString(R.styleable.CutoutViewIndicator_rcv_generator_class_name); if (generatorName != null) { CCGFactory.constructGeneratorFrom(context, attrs, defStyleAttr, generatorName, new ConstructorCallback() { @Override public void onGenerated(@NonNull CutoutCellGenerator generated) { CutoutViewIndicator.this.generator = generated; } @Override public void onFailed(@NonNull String message) { if (!message.isEmpty()) { Log.e(TAG, message); if (isInEditMode()) { String tag = "resources.invalid"; logger.logToLayoutLib(tag, message); } } } }); } setCellBackgroundId(a.getResourceId(R.styleable.CutoutViewIndicator_rcv_drawable_unselected, 0)); a.recycle(); } if (isInEditMode()) { setStateProxy(EDIT_MODE_PROXY); } } /** * {@inheritDoc} * * Most child views this ViewGroup will see are created by * {@link #createCellFor(int)} and added to this by the * {@link #dataSetObserver}. These objects all have non-null * CutoutCells associated with them. *

* Note that all other addView methods will ultimately call into * this one. Any child view that has not already been associated * with a CutoutCell via {@link #cells} will be forcibly * associated with one here (assuming a suitable implementation * can be obtained). *

*/ @Override public void addView(View child, int index, ViewGroup.LayoutParams params) { super.addView(child, index, params); // A CutoutCell's Views are always added at exact indices. If there is no value // in this.cells for the given index, we need to set that right away. int realIndex = indexOfChild(child); CutoutViewLayoutParams lp = (CutoutViewLayoutParams) child.getLayoutParams(); if (cells.get(realIndex) == null) { // This was NOT added via dataSetObserver! Flip a flag to ensure future recognition. lp.preservedDuringRebuild = true; CutoutCell cell = lp.getCutoutCell(); if (cell == null) { if (child instanceof ImageView) { cell = new CutoutImageCell((ImageView) child); warnAboutMisuseOf((ImageView) child, cell); } else if (child instanceof TextView) { cell = new CutoutTextCell((TextView) child); } else if (child instanceof CutoutCell) { cell = (CutoutCell) child; } lp.setCutoutCell(cell); } cells.put(realIndex, cell); } } /** * Call this when a View has been added to this (via {@link #addView}), to display any * warnings or errors due to misuse of the {@link CutoutCell}.....contract. * * @param child the child view in question * @param cell that child's CutoutCell (usually accessible through its LayoutParams) */ public void warnAboutMisuseOf(ImageView child, CutoutCell cell) { if (cell.getClass() == CutoutImageCell.class && child.getScaleType() != ImageView.ScaleType.MATRIX) { String tag = "resources.insufficient"; String message = "Only child ImageViews with a MATRIX scaleType are respected by" + " your choice of CutoutCell. Offset effects will not appear properly on" + " ScaleType " + child.getScaleType() + "."; logger.logToLayoutLib(tag, message); } } /** * Binds the special properties of {@link CutoutViewLayoutParams} to the * child in question. The child was previously added to this via some {@link #addView} * overload, so it can be safely assumed that its layout params fulfill all requirements * of {@link #checkLayoutParams(ViewGroup.LayoutParams)}. * * @param position what position this child possesses in terms of {@link #getChildAt(int)} * @param child a view that has just been added to this CutoutViewIndicator */ protected void bindChild(int position, View child) { CutoutViewLayoutParams lp = (CutoutViewLayoutParams) child.getLayoutParams(); lp.position = position; int cellLength; int perpendicularLength; int internalSpacing; if (cascadeChanges) { cellLength = getCellLength(); perpendicularLength = getPerpendicularLength(); internalSpacing = getInternalSpacing(); } else { cellLength = lp.cellLength; perpendicularLength = lp.perpendicularLength; internalSpacing = lp.internalSpacing; } final int left, top; if (getOrientation() == HORIZONTAL) { lp.width = cellLength; lp.height = perpendicularLength; left = (position == 0) ? 0 : internalSpacing; top = 0; } else { lp.width = perpendicularLength; lp.height = cellLength; left = 0; top = (position == 0) ? 0 : internalSpacing; } lp.setMargins(left, top, 0, 0); lp.gravity = Gravity.CENTER; if (isInEditMode() && lp.cellBackgroundId <= 0 && lp.indicatorDrawableId <= 0) { String tag = "resources.unusual"; String message = "Note that CutoutViewIndicator's generated views will not appear" + " unless you provide it with a positive drawable id" + " (i.e. for the attribute rcv_drawable)."; logger.logToLayoutLib(tag, message); } View originator; if (stateProxy instanceof ViewProvidingStateProxy) { originator = ((ViewProvidingStateProxy) stateProxy).getOriginalViewFor(position); } else { originator = null; } generator.onBindChild(child, lp, originator); } /** * Unbinding counterpart to {@link #bindChild(int, View)}. * * @param cell a {@link CutoutCell} originally returned * by {@link #createCellFor(int)}. */ public void unbindChild(CutoutCell cell) { ViewGroup.LayoutParams lp = cell.getItemView().getLayoutParams(); if (checkLayoutParams(lp)) { ((CutoutViewLayoutParams) lp).setCutoutCell(null); ((CutoutViewLayoutParams) lp).position = -1; } } /** * Set a new generator that will be called upon when a new cell * is needed. More or less the same as RecyclerView's Adapter. *

* Defaults to being a {@link ImageCellGenerator}, which * should be good enough for most purposes. *

* This method ends by calling {@link #rebuildChildViews()}. * * @param generator the new generator. May not be null. * @see #showOffsetIndicator(int, float) */ public void setGenerator(@NonNull CutoutCellGenerator generator) { this.generator = generator; rebuildChildViews(); } /** * Utility method for invalidating the {@link #generator} (so to speak). *

* It'll ensure that the child views match both what the current * {@link CutoutCellGenerator} creates AND the params defined by * {@link #defaultChildParams}. *

* Views which have been marked for * {@link CutoutViewLayoutParams#preservedDuringRebuild preservation} will * not be replaced. */ public void rebuildChildViews() { SparseArray preserved = cells.clone(); cells.clear(); for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); if (((CutoutViewLayoutParams)child.getLayoutParams()).preservedDuringRebuild) { cells.put(i, preserved.get(i)); } } preserved.clear(); removeAllViews(); dataSetObserver.onChanged(); } /** * Returns a reference to the generator used to create and bind * cells. Default value is an instance of {@link ImageCellGenerator}. * * @return the generator currently in use * @see #setGenerator(CutoutCellGenerator) */ @NonNull public CutoutCellGenerator getGenerator() { return generator; } /** * Asks the {@link #generator} to create a new cell. * * @param position used as 'index' parameter to {@link #addView(View, int)} * @return a new cell, appropriate for that position * @see CutoutCellGenerator#createCellFor(ViewGroup, int) * @see #showOffsetIndicator(int, float) */ @NonNull protected CutoutCell createCellFor(int position) { CutoutCell cell = generator.createCellFor(this, position); CutoutViewLayoutParams lp = (CutoutViewLayoutParams) cell.getItemView().getLayoutParams(); lp.setCutoutCell(cell); return cell; } /** * The caller is responsible for ensuring the parameters are within bounds. *

* In the below line drawing of a horizontal CutoutViewIndicator, we have 4 cells (a.k.a. child views). *
* ▓ is the indicator and ░ is the background color of each cell. *
* Note that the percentageOffset is a percentage of each cell that is drawn. * This class does not require that each child view is the same length, but it probably * looks better that way. *

*

*

     *             width of {@link CutoutViewLayoutParams#indicatorDrawableId the indicator}
     *              ┏━━━━━━━━━┻━━━━━━━━━┓
     *      position=0         position=1         position=2         position=3
     *     ┌──────────────┐   ┌──────────────┐   ┌──────────────┐   ┌──────────────┐
     *     │░░░░░░░░▓▓▓▓▓▓│   │▓▓▓▓▓▓▓▓▓░░░░░│   │░░░░░░░░░░░░░░│   │░░░░░░░░░░░░░░│
     *     │░░░░░░░░▓▓▓▓▓▓│   │▓▓▓▓▓▓▓▓▓░░░░░│   │░░░░░░░░░░░░░░│   │░░░░░░░░░░░░░░│
     *     └──────────────┘   └──────────────┘   └──────────────┘   └──────────────┘
     *      offset=8/14        offset=-8/14       offset=-22/14      offset=-36/14
     *              ┗━━━━━{@link #getCurrentIndicatorPosition() start}
     * 
*

* Additional information that must be passed to the CutoutCell but can not * be expressed solely with these two parameters may be sent through an overload * of this method. See {@link #showOffsetIndicator(int, IndicatorOffsetEvent)}. *

* * @param position corresponds to the view where an indicator should be shown. Must be less than {@link #getChildCount()} * @param percentageOffset how much of the indicator to draw (given as a value between -1 and 1). If out of range, no * indicator will be drawn */ public void showOffsetIndicator(int position, float percentageOffset) { showOffsetIndicator(position, new IndicatorOffsetEvent(position, percentageOffset)); } /** * Implementation-agnostic overload of {@link #showOffsetIndicator(int, float)}. Please * refer to that method's javadoc (with its line drawing) to understand precisely what * this method does. * * @param position corresponds to the view where an indicator should be shown. Must be less than {@link #getChildCount()} * @param offsetEvent encapsulates offset and orientation information. */ public void showOffsetIndicator(int position, @NonNull IndicatorOffsetEvent offsetEvent) { offsetEvent.setOrientation(getOrientation()); offsetEvent.setOffSetHints(offsetHints); CutoutCell child = getCutoutCellAt(position); // We have something to draw if (child != null) { child.offsetContentBy(offsetEvent); } } /** * Sets the {@link OffSetHint}s to be picked up by {@link IndicatorOffsetEvent#getOffSetHints()}. * * @param offsetHints the offsets that this CutoutViewIndicator should request */ public void setOffsetHints(@OffSetHint int offsetHints) { this.offsetHints = offsetHints; } /** * Setter for {@link #defaultChildParams} * * @param defaultChildParams sets default for all newly-created cells */ public void setDefaultChildParams(@NonNull CutoutViewLayoutParams defaultChildParams) { this.defaultChildParams = defaultChildParams; } /** * Use this to change whether the setters and getters apply to all views in this layout, * or just to the currently selected one. * * @param cascade true to enable cascading changes, false to disable */ public void cascadeParamChanges(boolean cascade) { this.cascadeChanges = cascade; requestLayout(); } /** * Wrapper around {@link #setDefaultChildParams(CutoutViewLayoutParams)} * that also applies these params to each of the child views. * * @param params the new parameters that all child views should use */ public void setAllChildParamsTo(@NonNull CutoutViewLayoutParams params) { boolean wasNotCascading = !cascadeChanges; if (wasNotCascading) { cascadeChanges = true; } setDefaultChildParams(params); for (int i = 0; i < cells.size(); i++) { CutoutCell cutoutCell = cells.valueAt(i); if (cutoutCell != null) { CutoutViewLayoutParams childParams = (CutoutViewLayoutParams) cutoutCell.getItemView().getLayoutParams(); childParams.setFrom(params); childParams.setCutoutCell(cutoutCell); } } if (wasNotCascading) { cascadeChanges = false; } requestLayout(); } /** * Gets the index of the page that's currently selected. * The value returned is guaranteed to be in the range {@code 0<=position *

* The floating point value returned here represents the exact start of the * current indicator, which in turn may be partially on two child views at the * same time. Callers should use their best judgement to determine whether to * consider both views current if it falls between integers, or do so for just * one of them. *

*

* If this view {@link #isInEditMode() is being edited}, the method * will return a random float in the range from 0 to {@link #getPageCount()}. *

* * @return the {@link StateProxy#getCurrentPosition()} if {@link #stateProxy} is non-null, * 0 otherwise */ private float getCurrentIndicatorPosition() { if (isInEditMode()) { return getPageCount() * (float) Math.random(); } return stateProxy.getCurrentPosition(); } /** * Gets the total number of pages. In {@link #isInEditMode() edit mode} * this will return 1 or {@link #editModePageCount}, whichever is higher. * * @return the number of pages found. */ protected int getPageCount() { if (isInEditMode()) { return Math.max(1, editModePageCount); } return stateProxy.getCellCount(); } /** * @param usePositiveOffset a new value for {@link #usePositiveOffset the associated field} */ public void enablePositiveOffset(boolean usePositiveOffset) { this.usePositiveOffset = usePositiveOffset; } /** * @param proposed the value returned by {@link CutoutViewIndicator#stateProxy} * @return the corrected value. * @see CutoutViewIndicator#usePositiveOffset */ public int fixPosition(int proposed) { if (usePositiveOffset) { // ViewPagers like SpinningViewPager are always off by one proposed--; // Ensure that it's positive if (proposed < 0) { proposed += getChildCount(); } } return proposed; } public void setCellBackgroundId(@DrawableRes int cellBackgroundId) { defaultChildParams.cellBackgroundId = cellBackgroundId; if (!cascadeChanges) { CutoutViewLayoutParams params = getLayoutParamsForCurrentItem(); if (params != null) { params.cellBackgroundId = cellBackgroundId; } } } public int getCellBackgroundId() { if (!cascadeChanges) { CutoutViewLayoutParams params = getLayoutParamsForCurrentItem(); if (params != null) { return params.cellBackgroundId; } } return defaultChildParams.cellBackgroundId; } public void setIndicatorDrawableId(@DrawableRes int indicatorDrawableId) { defaultChildParams.indicatorDrawableId = indicatorDrawableId; if (!cascadeChanges) { CutoutViewLayoutParams params = getLayoutParamsForCurrentItem(); if (params != null) { params.indicatorDrawableId = indicatorDrawableId; } } } /** * This is the id of the drawable currently acting as indicator. If 0, no indicator will be shown. * * @return that id, or 0 if this was not previously {@link #setIndicatorDrawableId(int) set} */ public int getIndicatorDrawableId() { if (!cascadeChanges) { CutoutViewLayoutParams params = getLayoutParamsForCurrentItem(); if (params != null) { return params.indicatorDrawableId; } } return defaultChildParams.indicatorDrawableId; } /** * This is the width of a cell when {@link #getOrientation() horizontal}, * but the height of a cell when {@link #getOrientation() vertical}. *

* All cells are the same proportions by default. *

* * @param cellLength any positive number of pixels, * {@link ViewGroup.LayoutParams#WRAP_CONTENT WRAP_CONTENT}, * or {@link ViewGroup.LayoutParams#MATCH_PARENT MATCH_PARENT} * @see #setPerpendicularLength(int) */ public void setCellLength(int cellLength) { defaultChildParams.cellLength = cellLength; boolean isHeight = getOrientation() == VERTICAL; defaultChildParams.setParamDimension(cellLength, isHeight); if (!cascadeChanges) { CutoutViewLayoutParams params = getLayoutParamsForCurrentItem(); if (params != null) { params.cellLength = cellLength; params.setParamDimension(cellLength, isHeight); } } requestLayout(); } /** * This is the space between cells. It is not added as padding to either end of * the {@code CutoutViewIndicator}. This view does not draw anything in these * spaces (except the background, if present). * * @param internalSpacing any positive number of pixels */ public void setInternalSpacing(int internalSpacing) { defaultChildParams.internalSpacing = internalSpacing; if (!cascadeChanges) { CutoutViewLayoutParams params = getLayoutParamsForCurrentItem(); if (params != null) { params.internalSpacing = internalSpacing; } } requestLayout(); } /** * This is the height of a cell when {@link #getOrientation() horizontal}, * but the width of a cell when {@link #getOrientation() vertical}. *

* All cells are the same proportions by default. *

* * @param perpendicularLength any positive number of pixels, * {@link ViewGroup.LayoutParams#WRAP_CONTENT WRAP_CONTENT}, * or {@link ViewGroup.LayoutParams#MATCH_PARENT MATCH_PARENT} * @see #setCellLength(int) */ public void setPerpendicularLength(int perpendicularLength) { defaultChildParams.perpendicularLength = perpendicularLength; boolean isHeight = getOrientation() == HORIZONTAL; defaultChildParams.setParamDimension(perpendicularLength, isHeight); if (!cascadeChanges) { CutoutViewLayoutParams params = getLayoutParamsForCurrentItem(); if (params != null) { params.perpendicularLength = perpendicularLength; params.setParamDimension(perpendicularLength, isHeight); } } requestLayout(); } /** * @see #setCellLength(int) * * @return current length of one cell in pixels */ public int getCellLength() { if (!cascadeChanges) { CutoutViewLayoutParams params = getLayoutParamsForCurrentItem(); if (params != null) { return params.cellLength; } } return defaultChildParams.cellLength; } /** * @see #setInternalSpacing(int) * * @return current space between cells in pixels */ public int getInternalSpacing() { if (!cascadeChanges) { CutoutViewLayoutParams params = getLayoutParamsForCurrentItem(); if (params != null) { return params.internalSpacing; } } return defaultChildParams.internalSpacing; } /** * @see #setPerpendicularLength(int) * * @return current perpendicular length of one cell in pixels */ public int getPerpendicularLength() { if (!cascadeChanges) { CutoutViewLayoutParams params = getLayoutParamsForCurrentItem(); if (params != null) { return params.perpendicularLength; } } return defaultChildParams.perpendicularLength; } /** * {@link ViewPager}-specific alias for {@link #setStateProxy(StateProxy)}. Note * that {@link CutoutViewIndicator} uses {@link StateProxy}s internally to get state * information. *

* Call this after setting the other custom parameters ({@link #setIndicatorDrawableId(int)}, * {@link #setCellLength(int)}, {@link #setInternalSpacing(int)}, {@link #setPerpendicularLength(int)}, * {@link #setCellBackgroundId(int)}) * to avoid redrawing or extra layout stuff. *

* * @param newPager the new ViewPager that this'll sync with. Pass null to disable. * @see ViewPagerStateProxy */ public void setViewPager(@Nullable ViewPager newPager) { StateProxy proxy; if (newPager == null) { proxy = null; } else { proxy = new ViewPagerStateProxy(newPager, this); } setStateProxy(proxy); } /** * Call this after setting the other custom parameters ({@link #setIndicatorDrawableId(int)}, * {@link #setCellLength(int)}, {@link #setInternalSpacing(int)}, {@link #setPerpendicularLength(int)}, * {@link #setCellBackgroundId(int)}) * to avoid redrawing or extra layout stuff. * * @param newStateProxy the new StateProxy that this'll sync with. Pass null to disable. */ public void setStateProxy(@Nullable StateProxy newStateProxy) { if (currentProxyMayDisassociate) { stateProxy.disassociateFrom(dataSetObserver); } if (newStateProxy == null) { newStateProxy = new UnavailableProxy(); } stateProxy = newStateProxy; if (newStateProxy.canObserve(dataSetObserver)) { currentProxyMayDisassociate = true; newStateProxy.associateWith(dataSetObserver); dataSetObserver.onChanged(); getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @SuppressWarnings("deprecation") @Override public void onGlobalLayout() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { getViewTreeObserver().removeOnGlobalLayoutListener(this); } else { getViewTreeObserver().removeGlobalOnLayoutListener(this); } ensureOnlyCurrentItemsSelected(); } }); } else { currentProxyMayDisassociate = false; } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (isInEditMode()) { // This is the only part of setStateProxy we care about in EditMode dataSetObserver.onChanged(); } for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); // Note that the superclass calls measure on the child for us bindChild(i, child); } 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); ensureOnlyCurrentItemsSelected(); } /** * Call this to hide the indicator on all views except for the one corresponding to the currently displaying item. *

* This method is relatively expensive and should only really be called * when no other animation is in progress on this view. To animate changes * smoothly it is recommended to use the slightly less comprehensive * {@link #showOffsetIndicator(int, float)} instead. *

*/ public void ensureOnlyCurrentItemsSelected() { final float current = getCurrentIndicatorPosition(); for (int i = 0; i < getChildCount(); i++) { // offset outside the range -1..1 puts it just off-view (i.e. hiding it) showOffsetIndicator(i, current - i); } } @Nullable protected CutoutViewLayoutParams getLayoutParamsForCurrentItem() { CutoutViewLayoutParams params = null; float position = getCurrentIndicatorPosition(); if (position >= 0) { CutoutCell cell = getCutoutCellAt((int) position); if (cell != null) { params = (CutoutViewLayoutParams) cell.getItemView().getLayoutParams(); } } return params; } @Nullable private CutoutCell getCutoutCellAt(int position) { return cells.get(position); } @Override protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { return p instanceof CutoutViewLayoutParams; } @Override public CutoutViewLayoutParams generateDefaultLayoutParams() { return new CutoutViewLayoutParams(defaultChildParams); } @Override public LayoutParams generateLayoutParams(AttributeSet attrs) { return new CutoutViewLayoutParams(getContext(), attrs, defaultChildParams); } @Override protected LayoutParams generateLayoutParams(@Nullable ViewGroup.LayoutParams p) { return CutoutViewLayoutParams.createFrom(p, defaultChildParams); } /** * Dedicated implementation of {@link DataSetObserver} for use with * {@link android.support.v4.view.PagerAdapter#registerDataSetObserver(DataSetObserver)} * and {@code RecyclerView.Adapter}. * * @see #onChanged() */ protected class IntegratedDataSetObserver extends DataSetObserver { /** * This method is called when the entire data set has changed, * most likely through a call to {@link Cursor#requery()} on a {@link Cursor}. * * In our case, this is most commonly triggered by {@link android.support.v4.view.PagerAdapter#notifyDataSetChanged()} */ @Override public void onChanged() { super.onChanged(); int childCount = getChildCount(); int pageCount = getPageCount(); // Unlike in RecyclerView, the individual CutoutCells are considered interchangeable. // Therefore the only thing that matters here is how many views there are supposed to be on screen int newViews = pageCount - childCount; if (newViews > 0) { // We're adding new views addNewViews(childCount, pageCount); } else if (newViews < 0) { // We're removing views removeViews(pageCount, -newViews); // Make sure to null out those references to CutoutCells for (int i = pageCount; i < childCount; i++) { unbindChild(cells.get(i)); } } else { // Quantity isn't changing. } IndicatorOffsetEvent event = stateProxy.resendPositionInfo(CutoutViewIndicator.this, getCurrentIndicatorPosition()); showOffsetIndicator(event.getPosition(), event); } /** * Calls {@link CutoutViewIndicator#addView} {@code pageCount - childCount} times. All new * views added are placed at the end of the CutoutViewIndicator. *

* If there is a non-null entry in {@link CutoutViewIndicator#cells} at the * position where this new view will be placed, then that value is queried * for its {@link CutoutCell#getItemView() itemView}. If that itemView is then * not null and not currently attached to a parent it will be added here. *

*

* However, if the CutoutCell's itemView is null or attached to a * {@link android.view.ViewParent parent}, it will not be used and a new * CutoutCell will be created (with a backing view) by * {@link CutoutViewIndicator#createCellFor(int)}. *

* At the end of this method, the entry in {@link CutoutViewIndicator#cells} for * each position of a newly-added View will contain a reference to that View's * {@link CutoutCell}. * @param childCount the number of views currently added as children of this * {@link CutoutViewIndicator} * @param pageCount the number of children this CutoutViewIndicator should * have to match the {@link CutoutViewIndicator#stateProxy}'s * {@link StateProxy#getCellCount() cell count}. */ protected void addNewViews(int childCount, int pageCount) { for (int i = childCount; i < pageCount; i++) { // This is a cached view CutoutCell cell = cells.get(i); if (cell == null || cell.getItemView().getParent() != null) { // Current CutoutCell is nonexistent or already in use elsewhere...create and add a new one! if (cell != null && cell.getItemView().getParent() == CutoutViewIndicator.this) { Log.w(TAG, "It would appear that the view at " + i + " was not removed properly."); } cell = createCellFor(i); cells.put(i, cell); } // This will invalidate the added view, triggering a call to this class's onMeasure() addView(cell.getItemView(), i); } } /** * This method is called when the entire data becomes invalid, * most likely through a call to {@link Cursor#deactivate()} or {@link Cursor#close()} on a * {@link Cursor}. *

* {@link ViewPager} doesn't really call this method. *

*/ @Override public void onInvalidated() { super.onInvalidated(); } } }