blob: 5c7fb00f83965f00b9681a01803ca713b16ef828 [file] [log] [blame]
/*
* Copyright 2017 The Android Open Source Project
*
* 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 androidx.viewpager2.widget;
import static java.lang.annotation.RetentionPolicy.SOURCE;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Rect;
import android.os.Build;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.util.AttributeSet;
import android.util.SparseArray;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.RequiresApi;
import androidx.core.view.ViewCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.PagerSnapHelper;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.Adapter;
import androidx.viewpager2.R;
import androidx.viewpager2.adapter.StatefulAdapter;
import java.lang.annotation.Retention;
/**
* ViewPager2 replaces {@link androidx.viewpager.widget.ViewPager}, addressing most of its
* predecessor’s pain-points, including right-to-left layout support, vertical orientation,
* modifiable Fragment collections, etc.
*
* @see androidx.viewpager.widget.ViewPager
*/
public final class ViewPager2 extends ViewGroup {
@Retention(SOURCE)
@IntDef({ORIENTATION_HORIZONTAL, ORIENTATION_VERTICAL})
public @interface Orientation {
}
public static final int ORIENTATION_HORIZONTAL = RecyclerView.HORIZONTAL;
public static final int ORIENTATION_VERTICAL = RecyclerView.VERTICAL;
@Retention(SOURCE)
@IntDef({SCROLL_STATE_IDLE, SCROLL_STATE_DRAGGING, SCROLL_STATE_SETTLING})
public @interface ScrollState {
}
public static final int SCROLL_STATE_IDLE = 0;
public static final int SCROLL_STATE_DRAGGING = 1;
public static final int SCROLL_STATE_SETTLING = 2;
// reused in layout(...)
private final Rect mTmpContainerRect = new Rect();
private final Rect mTmpChildRect = new Rect();
private CompositeOnPageChangeCallback mExternalPageChangeCallbacks =
new CompositeOnPageChangeCallback(3);
int mCurrentItem;
private RecyclerView mRecyclerView;
private LinearLayoutManager mLayoutManager;
private ScrollEventAdapter mScrollEventAdapter;
private PageTransformerAdapter mPageTransformerAdapter;
private CompositeOnPageChangeCallback mPageChangeEventDispatcher;
private boolean mUserInputEnabled = true;
public ViewPager2(@NonNull Context context) {
super(context);
initialize(context, null);
}
public ViewPager2(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
initialize(context, attrs);
}
public ViewPager2(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize(context, attrs);
}
@RequiresApi(21)
public ViewPager2(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr,
int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize(context, attrs);
}
private void initialize(Context context, AttributeSet attrs) {
mRecyclerView = new RecyclerViewImpl(context);
mRecyclerView.setId(ViewCompat.generateViewId());
mLayoutManager = new LinearLayoutManagerImpl(context);
mRecyclerView.setLayoutManager(mLayoutManager);
setOrientation(context, attrs);
mRecyclerView.setLayoutParams(
new ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
mRecyclerView.addOnChildAttachStateChangeListener(enforceChildFillListener());
new PagerSnapHelper().attachToRecyclerView(mRecyclerView);
mScrollEventAdapter = new ScrollEventAdapter(mLayoutManager);
mRecyclerView.addOnScrollListener(mScrollEventAdapter);
mPageChangeEventDispatcher = new CompositeOnPageChangeCallback(3);
mScrollEventAdapter.setOnPageChangeCallback(mPageChangeEventDispatcher);
// Callback that updates mCurrentItem after swipes. Also triggered in other cases, but in
// all those cases mCurrentItem will only be overwritten with the same value.
final OnPageChangeCallback currentItemUpdater = new OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
mCurrentItem = position;
}
};
// Add currentItemUpdater before mExternalPageChangeCallbacks, because we need to update
// internal state first
mPageChangeEventDispatcher.addOnPageChangeCallback(currentItemUpdater);
mPageChangeEventDispatcher.addOnPageChangeCallback(mExternalPageChangeCallbacks);
// Add mPageTransformerAdapter after mExternalPageChangeCallbacks, because page transform
// events must be fired after scroll events
mPageTransformerAdapter = new PageTransformerAdapter(mLayoutManager);
mPageChangeEventDispatcher.addOnPageChangeCallback(mPageTransformerAdapter);
attachViewToParent(mRecyclerView, 0, mRecyclerView.getLayoutParams());
}
/**
* A lot of places in code rely on an assumption that the page fills the whole ViewPager2.
*
* TODO(b/70666617) Allow page width different than width/height 100%/100%
*/
private RecyclerView.OnChildAttachStateChangeListener enforceChildFillListener() {
return new RecyclerView.OnChildAttachStateChangeListener() {
@Override
public void onChildViewAttachedToWindow(@NonNull View view) {
RecyclerView.LayoutParams layoutParams =
(RecyclerView.LayoutParams) view.getLayoutParams();
if (layoutParams.width != LayoutParams.MATCH_PARENT
|| layoutParams.height != LayoutParams.MATCH_PARENT) {
throw new IllegalStateException(
"Pages must fill the whole ViewPager2 (use match_parent)");
}
}
@Override
public void onChildViewDetachedFromWindow(@NonNull View view) {
// nothing
}
};
}
private void setOrientation(Context context, AttributeSet attrs) {
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ViewPager2);
try {
setOrientation(
a.getInt(R.styleable.ViewPager2_android_orientation, ORIENTATION_HORIZONTAL));
} finally {
a.recycle();
}
}
@Nullable
@Override
protected Parcelable onSaveInstanceState() {
Parcelable superState = super.onSaveInstanceState();
SavedState ss = new SavedState(superState);
ss.mRecyclerViewId = mRecyclerView.getId();
ss.mOrientation = getOrientation();
ss.mCurrentItem = mCurrentItem;
ss.mUserScrollable = mUserInputEnabled;
ss.mScrollInProgress =
mLayoutManager.findFirstCompletelyVisibleItemPosition() != mCurrentItem;
Adapter adapter = mRecyclerView.getAdapter();
if (adapter instanceof StatefulAdapter) {
ss.mAdapterState = ((StatefulAdapter) adapter).saveState();
}
return ss;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (!(state instanceof SavedState)) {
super.onRestoreInstanceState(state);
return;
}
SavedState ss = (SavedState) state;
super.onRestoreInstanceState(ss.getSuperState());
setOrientation(ss.mOrientation);
mCurrentItem = ss.mCurrentItem;
mUserInputEnabled = ss.mUserScrollable;
if (ss.mScrollInProgress) {
// A scroll was in progress, so the RecyclerView is not at mCurrentItem right now. Move
// it to mCurrentItem instantly in the _next_ frame, as RecyclerView is not yet fired up
// at this moment. Remove the event dispatcher during this time, as it will fire a
// scroll event for the current position, which has already been fired before the config
// change.
final ScrollEventAdapter scrollEventAdapter = mScrollEventAdapter;
final OnPageChangeCallback eventDispatcher = mPageChangeEventDispatcher;
scrollEventAdapter.setOnPageChangeCallback(null);
final RecyclerView recyclerView = mRecyclerView; // to avoid a synthetic accessor
recyclerView.post(new Runnable() {
@Override
public void run() {
scrollEventAdapter.setOnPageChangeCallback(eventDispatcher);
scrollEventAdapter.notifyRestoreCurrentItem(mCurrentItem);
recyclerView.scrollToPosition(mCurrentItem);
}
});
} else {
mScrollEventAdapter.notifyRestoreCurrentItem(mCurrentItem);
}
if (ss.mAdapterState != null) {
Adapter adapter = mRecyclerView.getAdapter();
if (adapter instanceof StatefulAdapter) {
((StatefulAdapter) adapter).restoreState(ss.mAdapterState);
}
}
}
@Override
protected void dispatchRestoreInstanceState(SparseArray<Parcelable> container) {
// RecyclerView changed an id, so we need to reflect that in the saved state
Parcelable state = container.get(getId());
if (state instanceof SavedState) {
final int previousRvId = ((SavedState) state).mRecyclerViewId;
final int currentRvId = mRecyclerView.getId();
container.put(currentRvId, container.get(previousRvId));
container.remove(previousRvId);
}
super.dispatchRestoreInstanceState(container);
}
static class SavedState extends BaseSavedState {
int mRecyclerViewId;
@Orientation int mOrientation;
int mCurrentItem;
boolean mUserScrollable;
boolean mScrollInProgress;
Parcelable mAdapterState;
@RequiresApi(24)
SavedState(Parcel source, ClassLoader loader) {
super(source, loader);
readValues(source, loader);
}
SavedState(Parcel source) {
super(source);
readValues(source, null);
}
SavedState(Parcelable superState) {
super(superState);
}
private void readValues(Parcel source, ClassLoader loader) {
mRecyclerViewId = source.readInt();
mOrientation = source.readInt();
mCurrentItem = source.readInt();
mUserScrollable = source.readByte() != 0;
mScrollInProgress = source.readByte() != 0;
mAdapterState = source.readParcelable(loader);
}
@Override
public void writeToParcel(Parcel out, int flags) {
super.writeToParcel(out, flags);
out.writeInt(mRecyclerViewId);
out.writeInt(mOrientation);
out.writeInt(mCurrentItem);
out.writeByte((byte) (mUserScrollable ? 1 : 0));
out.writeByte((byte) (mScrollInProgress ? 1 : 0));
out.writeParcelable(mAdapterState, flags);
}
static final Creator<SavedState> CREATOR = new ClassLoaderCreator<SavedState>() {
@Override
public SavedState createFromParcel(Parcel source, ClassLoader loader) {
return Build.VERSION.SDK_INT >= 24
? new SavedState(source, loader)
: new SavedState(source);
}
@Override
public SavedState createFromParcel(Parcel source) {
return createFromParcel(source, null);
}
@Override
public SavedState[] newArray(int size) {
return new SavedState[size];
}
};
}
/**
* @see androidx.viewpager2.adapter.FragmentStateAdapter
* @see RecyclerView#setAdapter(Adapter)
*/
public void setAdapter(@Nullable Adapter adapter) {
mRecyclerView.setAdapter(adapter);
}
public @Nullable Adapter getAdapter() {
return mRecyclerView.getAdapter();
}
@Override
public void onViewAdded(View child) {
// TODO(b/70666620): consider adding a support for Decor views
throw new IllegalStateException(
getClass().getSimpleName() + " does not support direct child views");
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// TODO(b/70666622): consider margin support
// TODO(b/70666626): consider delegating all this to RecyclerView
// TODO(b/70666625): write automated tests for this
measureChild(mRecyclerView, widthMeasureSpec, heightMeasureSpec);
int width = mRecyclerView.getMeasuredWidth();
int height = mRecyclerView.getMeasuredHeight();
int childState = mRecyclerView.getMeasuredState();
width += getPaddingLeft() + getPaddingRight();
height += getPaddingTop() + getPaddingBottom();
width = Math.max(width, getSuggestedMinimumWidth());
height = Math.max(height, getSuggestedMinimumHeight());
setMeasuredDimension(resolveSizeAndState(width, widthMeasureSpec, childState),
resolveSizeAndState(height, heightMeasureSpec,
childState << MEASURED_HEIGHT_STATE_SHIFT));
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int width = mRecyclerView.getMeasuredWidth();
int height = mRecyclerView.getMeasuredHeight();
// TODO(b/70666626): consider delegating padding handling to the RecyclerView to avoid
// an unnatural page transition effect: http://shortn/_Vnug3yZpQT
mTmpContainerRect.left = getPaddingLeft();
mTmpContainerRect.right = r - l - getPaddingRight();
mTmpContainerRect.top = getPaddingTop();
mTmpContainerRect.bottom = b - t - getPaddingBottom();
Gravity.apply(Gravity.TOP | Gravity.START, width, height, mTmpContainerRect, mTmpChildRect);
mRecyclerView.layout(mTmpChildRect.left, mTmpChildRect.top, mTmpChildRect.right,
mTmpChildRect.bottom);
}
/**
* @param orientation @{link {@link ViewPager2.Orientation}}
*/
public void setOrientation(@Orientation int orientation) {
mLayoutManager.setOrientation(orientation);
}
public @Orientation int getOrientation() {
return mLayoutManager.getOrientation();
}
/**
* Set the currently selected page. If the ViewPager has already been through its first
* layout with its current adapter there will be a smooth animated transition between
* the current item and the specified item. Silently ignored if the adapter is not set or
* empty. Clamps item to the bounds of the adapter.
*
* TODO(b/123069219): verify first layout behavior
*
* @param item Item index to select
*/
public void setCurrentItem(int item) {
setCurrentItem(item, true);
}
/**
* Set the currently selected page. If {@code smoothScroll = true}, will perform a smooth
* animation from the current item to the new item. Silently ignored if the adapter is not set
* or empty. Clamps item to the bounds of the adapter.
*
* @param item Item index to select
* @param smoothScroll True to smoothly scroll to the new item, false to transition immediately
*/
public void setCurrentItem(int item, boolean smoothScroll) {
Adapter adapter = getAdapter();
if (adapter == null || adapter.getItemCount() <= 0) {
return;
}
item = Math.max(item, 0);
item = Math.min(item, adapter.getItemCount() - 1);
if (item == mCurrentItem && mScrollEventAdapter.isIdle()) {
// Already at the correct page
return;
}
if (item == mCurrentItem && smoothScroll) {
// Already scrolling to the correct page, but not yet there. Only handle instant scrolls
// because then we need to interrupt the current smooth scroll.
return;
}
float previousItem = mCurrentItem;
mCurrentItem = item;
if (!mScrollEventAdapter.isIdle()) {
// Scroll in progress, overwrite previousItem with actual current position
previousItem = mScrollEventAdapter.getRelativeScrollPosition();
}
mScrollEventAdapter.notifyProgrammaticScroll(item, smoothScroll);
if (!smoothScroll) {
mRecyclerView.scrollToPosition(item);
return;
}
// For smooth scroll, pre-jump to nearby item for long jumps.
if (Math.abs(item - previousItem) > 3) {
mRecyclerView.scrollToPosition(item > previousItem ? item - 3 : item + 3);
// TODO(b/114361680): call smoothScrollToPosition synchronously (blocked by b/114019007)
mRecyclerView.post(new SmoothScrollToPosition(item, mRecyclerView));
} else {
mRecyclerView.smoothScrollToPosition(item);
}
}
/**
* Returns the currently selected page. If no page can sensibly be selected because there is no
* adapter or the adapter is empty, returns 0.
*
* @return Currently selected page
*/
public int getCurrentItem() {
return mCurrentItem;
}
/**
* Enable or disable user initiated scrolling. This includes touch input (scroll and fling
* gestures) and accessibility input. Disabling keyboard input is not yet supported. When user
* initiated scrolling is disabled, programmatic scrolls through {@link #setCurrentItem(int,
* boolean) setCurrentItem} still work. By default, user initiated scrolling is enabled.
*
* @param enabled {@code true} to allow user initiated scrolling, {@code false} to block user
* initiated scrolling
* @see #isUserInputEnabled()
*/
public void setUserInputEnabled(boolean enabled) {
mUserInputEnabled = enabled;
}
/**
* Returns if user initiated scrolling between pages is enabled. Enabled by default.
*
* @return {@code true} if users can scroll the ViewPager2, {@code false} otherwise
* @see #setUserInputEnabled(boolean)
*/
public boolean isUserInputEnabled() {
return mUserInputEnabled;
}
/**
* Add a callback that will be invoked whenever the page changes or is incrementally
* scrolled. See {@link OnPageChangeCallback}.
*
* <p>Components that add a callback should take care to remove it when finished.
*
* @param callback callback to add
*/
public void registerOnPageChangeCallback(@NonNull OnPageChangeCallback callback) {
mExternalPageChangeCallbacks.addOnPageChangeCallback(callback);
}
/**
* Remove a callback that was previously added via
* {@link #registerOnPageChangeCallback(OnPageChangeCallback)}.
*
* @param callback callback to remove
*/
public void unregisterOnPageChangeCallback(@NonNull OnPageChangeCallback callback) {
mExternalPageChangeCallbacks.removeOnPageChangeCallback(callback);
}
/**
* Sets a {@link PageTransformer} that will be called for each attached page whenever the
* scroll position is changed. This allows the application to apply custom property
* transformations to each page, overriding the default sliding behavior.
*
* @param transformer PageTransformer that will modify each page's animation properties
*/
public void setPageTransformer(@Nullable PageTransformer transformer) {
// TODO: add support for reverseDrawingOrder: b/112892792
// TODO: add support for pageLayerType: b/112893074
mPageTransformerAdapter.setPageTransformer(transformer);
}
/**
* Slightly modified RecyclerView to get ViewPager behavior in accessibility and to
* enable/disable user scrolling.
*/
private class RecyclerViewImpl extends RecyclerView {
RecyclerViewImpl(@NonNull Context context) {
super(context);
}
@Override
public CharSequence getAccessibilityClassName() {
return "androidx.viewpager.widget.ViewPager";
}
@Override
public void onInitializeAccessibilityEvent(@NonNull AccessibilityEvent event) {
super.onInitializeAccessibilityEvent(event);
event.setFromIndex(mCurrentItem);
event.setToIndex(mCurrentItem);
}
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent event) {
return isUserInputEnabled() && super.onTouchEvent(event);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return isUserInputEnabled() && super.onInterceptTouchEvent(ev);
}
}
/**
* Slightly modified LinearLayoutManager to adjust accessibility when user scrolling is
* disabled.
*/
private class LinearLayoutManagerImpl extends LinearLayoutManager {
LinearLayoutManagerImpl(Context context) {
super(context);
}
@Override
public boolean performAccessibilityAction(@NonNull RecyclerView.Recycler recycler,
@NonNull RecyclerView.State state, int action, @Nullable Bundle args) {
switch (action) {
case AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD:
case AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD:
if (!isUserInputEnabled()) {
return false;
}
break;
}
return super.performAccessibilityAction(recycler, state, action, args);
}
@Override
public void onInitializeAccessibilityNodeInfo(@NonNull RecyclerView.Recycler recycler,
@NonNull RecyclerView.State state, @NonNull AccessibilityNodeInfoCompat info) {
super.onInitializeAccessibilityNodeInfo(recycler, state, info);
if (!isUserInputEnabled()) {
info.removeAction(AccessibilityActionCompat.ACTION_SCROLL_BACKWARD);
info.removeAction(AccessibilityActionCompat.ACTION_SCROLL_FORWARD);
info.setScrollable(false);
}
}
}
private static class SmoothScrollToPosition implements Runnable {
private final int mPosition;
private final RecyclerView mRecyclerView;
SmoothScrollToPosition(int position, RecyclerView recyclerView) {
mPosition = position;
mRecyclerView = recyclerView; // to avoid a synthetic accessor
}
@Override
public void run() {
mRecyclerView.smoothScrollToPosition(mPosition);
}
}
/**
* Callback interface for responding to changing state of the selected page.
*/
public abstract static class OnPageChangeCallback {
/**
* This method will be invoked when the current page is scrolled, either as part
* of a programmatically initiated smooth scroll or a user initiated touch scroll.
*
* @param position Position index of the first page currently being displayed.
* Page position+1 will be visible if positionOffset is nonzero.
* @param positionOffset Value from [0, 1) indicating the offset from the page at position.
* @param positionOffsetPixels Value in pixels indicating the offset from position.
*/
public void onPageScrolled(int position, float positionOffset,
@Px int positionOffsetPixels) {
}
/**
* This method will be invoked when a new page becomes selected. Animation is not
* necessarily complete.
*
* @param position Position index of the new selected page.
*/
public void onPageSelected(int position) {
}
/**
* Called when the scroll state changes. Useful for discovering when the user
* begins dragging, when the pager is automatically settling to the current page,
* or when it is fully stopped/idle.
*/
public void onPageScrollStateChanged(@ScrollState int state) {
}
}
/**
* A PageTransformer is invoked whenever a visible/attached page is scrolled.
* This offers an opportunity for the application to apply a custom transformation
* to the page views using animation properties.
*
* <p>As property animation is only supported as of Android 3.0 and forward,
* setting a PageTransformer on a ViewPager on earlier platform versions will
* be ignored.</p>
*/
public interface PageTransformer {
/**
* Apply a property transformation to the given page.
*
* @param page Apply the transformation to this page
* @param position Position of page relative to the current front-and-center
* position of the pager. 0 is front and center. 1 is one full
* page position to the right, and -1 is one page position to the left.
*/
void transformPage(@NonNull View page, float position);
}
}