在 Android 中,滚动通常通过使用 ScrollView 类来实现。将可能超出其容器边界的任何标准布局嵌套在 ScrollView 中,以提供由框架管理的可滚动视图。只有在特殊场景下,才需要实现自定义滚动条。本文档介绍了如何使用滚动条显示滚动效果以响应轻触手势。
您的应用可以使用滚动条(Scroller 或 OverScroller)来收集生成滚动动画所需的数据,以响应触摸事件。它们很相似,但 OverScroller 还包含一些方法,用于在用户执行平移或快速滑动操作后到达内容边缘时向用户发出指示。
- 从 Android 12(API 级别 31)开始,视觉元素会在拖动事件中拉伸和回弹,在快速滑动事件中快速滑动和回弹。
- 在 Android 11(API 级别 30)及更低版本中,边界会在拖动或快速滑动到边缘后显示“发光”效果。
本文档中的 InteractiveChart 示例使用 EdgeEffect 类来显示这些滚动回弹效果。
您可以使用滚动条,通过摩擦力、速度和其他特性等平台标准滚动物理特性,让滚动效果随时间推移而呈现动画效果。滚动条本身不会绘制任何内容。滚动条会跟踪一段时间内的滚动偏移量,但不会自动将这些位置应用到视图。您必须以一定的速率获取并应用新坐标,使滚动动画看起来流畅。
了解滚动术语
在 Android 中,“滚动”一词在不同的上下文中可能有不同的含义。
滚动是移动视口(即您正在查看的内容“窗口”)的一般过程。当滚动同时在 x 轴和 y 轴上进行时,称为平移。本文档中的 InteractiveChart 示例应用展示了两种不同类型的滚动、拖动和快速滑动:
- 拖动:这是用户在触摸屏上拖动手指时发生的滚动类型。您可以通过替换
GestureDetector.OnGestureListener中的onScroll()来实现拖动。 如需详细了解拖动,请参阅拖动和缩放。 - 快速滑动:当用户快速拖动并抬起手指时,就会发生这种类型的滚动。用户抬起手指后,您通常需要让视口继续移动,但要减速,直到视口停止移动。您可以通过在
GestureDetector.OnGestureListener中替换onFling()并使用滚动条对象来实现快速滑动。 - 平移:同时沿 x 轴和 y 轴滚动称为平移。
通常,Scroller 对象会与快速滑动手势结合使用,但您也可以在任何希望界面通过滚动来响应触摸事件的情况下使用 Scroller 对象。例如,您可以替换 onTouchEvent(),以直接处理触摸事件,并产生滚动效果或“贴靠到页面”动画来响应这些触摸事件。
包含内置滚动实现的组件
以下 Android 组件内置了对滚动和滚动回弹行为的支持:
GridViewHorizontalScrollViewListViewNestedScrollViewRecyclerViewScrollViewViewPagerViewPager2
如果您的应用需要在其他组件内支持滚动和滚动回弹,请完成以下步骤:
- 创建自定义的基于轻触的滚动实现。
- 如需支持搭载 Android 12 及更高版本的设备,请实现拉伸滚动回弹效果。
创建自定义的基于轻触的滚动实现
本部分介绍了如何在应用使用的组件不包含对滚动和滚动回弹的内置支持时创建自己的滚动条。
以下代码段来自 InteractiveChart 示例。它使用 GestureDetector 并替换 GestureDetector.SimpleOnGestureListener 方法 onFling()。它使用 OverScroller 跟踪快速滑动。如果用户在执行快速滑动手势后到达内容边缘,容器会指示用户何时到达内容末尾。指示方式取决于设备搭载的 Android 版本:
- 在 Android 12 及更高版本中,视觉元素会拉伸并回弹。
- 在 Android 11 及更低版本中,视觉元素会显示发光效果。
以下代码段的第一部分展示了 onFling() 的实现:
Kotlin
// Viewport extremes. See currentViewport for a discussion of the viewport. private val AXIS_X_MIN = -1f private val AXIS_X_MAX = 1f private val AXIS_Y_MIN = -1f private val AXIS_Y_MAX = 1f // The current viewport. This rectangle represents the visible chart // domain and range. The viewport is the part of the app that the // user manipulates via touch gestures. private val currentViewport = RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX) // The current destination rectangle—in pixel coordinates—into which // the chart data must be drawn. private lateinit var contentRect: Rect private lateinit var scroller: OverScroller private lateinit var scrollerStartViewport: RectF ... private val gestureListener = object : GestureDetector.SimpleOnGestureListener() { override fun onDown(e: MotionEvent): Boolean { // Initiates the decay phase of any active edge effects. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { releaseEdgeEffects() } scrollerStartViewport.set(currentViewport) // Aborts any active scroll animations and invalidates. scroller.forceFinished(true) ViewCompat.postInvalidateOnAnimation(this@InteractiveLineGraphView) return true } ... override fun onFling( e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float ): Boolean { fling((-velocityX).toInt(), (-velocityY).toInt()) return true } } private fun fling(velocityX: Int, velocityY: Int) { // Initiates the decay phase of any active edge effects. // On Android 12 and later, the edge effect (stretch) must // continue. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { releaseEdgeEffects() } // Flings use math in pixels, as opposed to math based on the viewport. val surfaceSize: Point = computeScrollSurfaceSize() val (startX: Int, startY: Int) = scrollerStartViewport.run { set(currentViewport) (surfaceSize.x * (left - AXIS_X_MIN) / (AXIS_X_MAX - AXIS_X_MIN)).toInt() to (surfaceSize.y * (AXIS_Y_MAX - bottom) / (AXIS_Y_MAX - AXIS_Y_MIN)).toInt() } // Before flinging, stops the current animation. scroller.forceFinished(true) // Begins the animation. scroller.fling( // Current scroll position. startX, startY, velocityX, velocityY, /* * Minimum and maximum scroll positions. The minimum scroll * position is generally 0 and the maximum scroll position * is generally the content size less the screen size. So if the * content width is 1000 pixels and the screen width is 200 * pixels, the maximum scroll offset is 800 pixels. */ 0, surfaceSize.x - contentRect.width(), 0, surfaceSize.y - contentRect.height(), // The edges of the content. This comes into play when using // the EdgeEffect class to draw "glow" overlays. contentRect.width() / 2, contentRect.height() / 2 ) // Invalidates to trigger computeScroll(). ViewCompat.postInvalidateOnAnimation(this) }
Java
// Viewport extremes. See currentViewport for a discussion of the viewport. private static final float AXIS_X_MIN = -1f; private static final float AXIS_X_MAX = 1f; private static final float AXIS_Y_MIN = -1f; private static final float AXIS_Y_MAX = 1f; // The current viewport. This rectangle represents the visible chart // domain and range. The viewport is the part of the app that the // user manipulates via touch gestures. private RectF currentViewport = new RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX); // The current destination rectangle—in pixel coordinates—into which // the chart data must be drawn. private final Rect contentRect = new Rect(); private final OverScroller scroller; private final RectF scrollerStartViewport = new RectF(); // Used only for zooms and flings. ... private final GestureDetector.SimpleOnGestureListener gestureListener = new GestureDetector.SimpleOnGestureListener() { @Override public boolean onDown(MotionEvent e) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { releaseEdgeEffects(); } scrollerStartViewport.set(currentViewport); scroller.forceFinished(true); ViewCompat.postInvalidateOnAnimation(InteractiveLineGraphView.this); return true; } ... @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { fling((int) -velocityX, (int) -velocityY); return true; } }; private void fling(int velocityX, int velocityY) { // Initiates the decay phase of any active edge effects. // On Android 12 and later, the edge effect (stretch) must // continue. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { releaseEdgeEffects(); } // Flings use math in pixels, as opposed to math based on the viewport. Point surfaceSize = computeScrollSurfaceSize(); scrollerStartViewport.set(currentViewport); int startX = (int) (surfaceSize.x * (scrollerStartViewport.left - AXIS_X_MIN) / ( AXIS_X_MAX - AXIS_X_MIN)); int startY = (int) (surfaceSize.y * (AXIS_Y_MAX - scrollerStartViewport.bottom) / ( AXIS_Y_MAX - AXIS_Y_MIN)); // Before flinging, stops the current animation. scroller.forceFinished(true); // Begins the animation. scroller.fling( // Current scroll position. startX, startY, velocityX, velocityY, /* * Minimum and maximum scroll positions. The minimum scroll * position is generally 0 and the maximum scroll position * is generally the content size less the screen size. So if the * content width is 1000 pixels and the screen width is 200 * pixels, the maximum scroll offset is 800 pixels. */ 0, surfaceSize.x - contentRect.width(), 0, surfaceSize.y - contentRect.height(), // The edges of the content. This comes into play when using // the EdgeEffect class to draw "glow" overlays. contentRect.width() / 2, contentRect.height() / 2); // Invalidates to trigger computeScroll(). ViewCompat.postInvalidateOnAnimation(this); }
当 onFling() 调用 postInvalidateOnAnimation() 时,它会触发 computeScroll() 来更新 x 和 y 的值。这通常在子视图使用滚动条对象为滚动添加动画效果时完成,如前面的示例所示。
大多数视图会将滚动条对象的 x 和 y 位置直接传递给 scrollTo()。以下 computeScroll() 实现采用不同的方法:它调用 computeScrollOffset() 来获取 x 和 y 的当前位置。当满足显示滚动回弹“发光”边缘效果的条件时(即显示屏已放大、x 或 y 超出边界,并且应用尚未显示滚动回弹),代码会设置滚动回弹发光效果,并调用 postInvalidateOnAnimation() 来触发视图失效。
Kotlin
// Edge effect/overscroll tracking objects. private lateinit var edgeEffectTop: EdgeEffect private lateinit var edgeEffectBottom: EdgeEffect private lateinit var edgeEffectLeft: EdgeEffect private lateinit var edgeEffectRight: EdgeEffect private var edgeEffectTopActive: Boolean = false private var edgeEffectBottomActive: Boolean = false private var edgeEffectLeftActive: Boolean = false private var edgeEffectRightActive: Boolean = false override fun computeScroll() { super.computeScroll() var needsInvalidate = false // The scroller isn't finished, meaning a fling or // programmatic pan operation is active. if (scroller.computeScrollOffset()) { val surfaceSize: Point = computeScrollSurfaceSize() val currX: Int = scroller.currX val currY: Int = scroller.currY val (canScrollX: Boolean, canScrollY: Boolean) = currentViewport.run { (left > AXIS_X_MIN || right < AXIS_X_MAX) to (top > AXIS_Y_MIN || bottom < AXIS_Y_MAX) } /* * If you are zoomed in, currX or currY is * outside of bounds, and you aren't already * showing overscroll, then render the overscroll * glow edge effect. */ if (canScrollX && currX < 0 && edgeEffectLeft.isFinished && !edgeEffectLeftActive) { edgeEffectLeft.onAbsorb(scroller.currVelocity.toInt()) edgeEffectLeftActive = true needsInvalidate = true } else if (canScrollX && currX > surfaceSize.x - contentRect.width() && edgeEffectRight.isFinished && !edgeEffectRightActive) { edgeEffectRight.onAbsorb(scroller.currVelocity.toInt()) edgeEffectRightActive = true needsInvalidate = true } if (canScrollY && currY < 0 && edgeEffectTop.isFinished && !edgeEffectTopActive) { edgeEffectTop.onAbsorb(scroller.currVelocity.toInt()) edgeEffectTopActive = true needsInvalidate = true } else if (canScrollY && currY > surfaceSize.y - contentRect.height() && edgeEffectBottom.isFinished && !edgeEffectBottomActive) { edgeEffectBottom.onAbsorb(scroller.currVelocity.toInt()) edgeEffectBottomActive = true needsInvalidate = true } ... } }
Java
// Edge effect/overscroll tracking objects. private EdgeEffectCompat edgeEffectTop; private EdgeEffectCompat edgeEffectBottom; private EdgeEffectCompat edgeEffectLeft; private EdgeEffectCompat edgeEffectRight; private boolean edgeEffectTopActive; private boolean edgeEffectBottomActive; private boolean edgeEffectLeftActive; private boolean edgeEffectRightActive; @Override public void computeScroll() { super.computeScroll(); boolean needsInvalidate = false; // The scroller isn't finished, meaning a fling or // programmatic pan operation is active. if (scroller.computeScrollOffset()) { Point surfaceSize = computeScrollSurfaceSize(); int currX = scroller.getCurrX(); int currY = scroller.getCurrY(); boolean canScrollX = (currentViewport.left > AXIS_X_MIN || currentViewport.right < AXIS_X_MAX); boolean canScrollY = (currentViewport.top > AXIS_Y_MIN || currentViewport.bottom < AXIS_Y_MAX); /* * If you are zoomed in, currX or currY is * outside of bounds, and you aren't already * showing overscroll, then render the overscroll * glow edge effect. */ if (canScrollX && currX < 0 && edgeEffectLeft.isFinished() && !edgeEffectLeftActive) { edgeEffectLeft.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectLeftActive = true; needsInvalidate = true; } else if (canScrollX && currX > (surfaceSize.x - contentRect.width()) && edgeEffectRight.isFinished() && !edgeEffectRightActive) { edgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectRightActive = true; needsInvalidate = true; } if (canScrollY && currY < 0 && edgeEffectTop.isFinished() && !edgeEffectTopActive) { edgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectTopActive = true; needsInvalidate = true; } else if (canScrollY && currY > (surfaceSize.y - contentRect.height()) && edgeEffectBottom.isFinished() && !edgeEffectBottomActive) { edgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectBottomActive = true; needsInvalidate = true; } ... }
以下是执行实际缩放的代码部分:
Kotlin
lateinit var zoomer: Zoomer val zoomFocalPoint = PointF() ... // If a zoom is in progress—either programmatically // or through double touch—this performs the zoom. if (zoomer.computeZoom()) { val newWidth: Float = (1f - zoomer.currZoom) * scrollerStartViewport.width() val newHeight: Float = (1f - zoomer.currZoom) * scrollerStartViewport.height() val pointWithinViewportX: Float = (zoomFocalPoint.x - scrollerStartViewport.left) / scrollerStartViewport.width() val pointWithinViewportY: Float = (zoomFocalPoint.y - scrollerStartViewport.top) / scrollerStartViewport.height() currentViewport.set( zoomFocalPoint.x - newWidth * pointWithinViewportX, zoomFocalPoint.y - newHeight * pointWithinViewportY, zoomFocalPoint.x + newWidth * (1 - pointWithinViewportX), zoomFocalPoint.y + newHeight * (1 - pointWithinViewportY) ) constrainViewport() needsInvalidate = true } if (needsInvalidate) { ViewCompat.postInvalidateOnAnimation(this) }
Java
// Custom object that is functionally similar to Scroller. Zoomer zoomer; private PointF zoomFocalPoint = new PointF(); ... // If a zoom is in progress—either programmatically // or through double touch—this performs the zoom. if (zoomer.computeZoom()) { float newWidth = (1f - zoomer.getCurrZoom()) * scrollerStartViewport.width(); float newHeight = (1f - zoomer.getCurrZoom()) * scrollerStartViewport.height(); float pointWithinViewportX = (zoomFocalPoint.x - scrollerStartViewport.left) / scrollerStartViewport.width(); float pointWithinViewportY = (zoomFocalPoint.y - scrollerStartViewport.top) / scrollerStartViewport.height(); currentViewport.set( zoomFocalPoint.x - newWidth * pointWithinViewportX, zoomFocalPoint.y - newHeight * pointWithinViewportY, zoomFocalPoint.x + newWidth * (1 - pointWithinViewportX), zoomFocalPoint.y + newHeight * (1 - pointWithinViewportY)); constrainViewport(); needsInvalidate = true; } if (needsInvalidate) { ViewCompat.postInvalidateOnAnimation(this); }
这是在前面的代码段中调用的 computeScrollSurfaceSize() 方法。它会计算当前可滚动界面的大小(以像素为单位)。例如,如果整个图表区域可见,则这是 mContentRect 的当前大小。如果图表在两个方向上都放大 200%,则返回的尺寸在水平和垂直方向上都会增大一倍。
Kotlin
private fun computeScrollSurfaceSize(): Point { return Point( (contentRect.width() * (AXIS_X_MAX - AXIS_X_MIN) / currentViewport.width()).toInt(), (contentRect.height() * (AXIS_Y_MAX - AXIS_Y_MIN) / currentViewport.height()).toInt() ) }
Java
private Point computeScrollSurfaceSize() { return new Point( (int) (contentRect.width() * (AXIS_X_MAX - AXIS_X_MIN) / currentViewport.width()), (int) (contentRect.height() * (AXIS_Y_MAX - AXIS_Y_MIN) / currentViewport.height())); }
如需查看滚动条用法的另一个示例,请参阅 ViewPager 类的源代码。它会根据快速滑动操作进行滚动,并使用滚动来实现“贴靠到页面”动画。
实现拉伸滚动回弹效果
从 Android 12 开始,EdgeEffect 添加了以下 API,用于实现拉伸滚动回弹效果:
getDistance()onPullDistance()
为了通过拉伸滚动回弹提供最佳用户体验,请执行以下操作:
- 当拉伸动画在用户轻触内容时生效时,将轻触注册为“捕获”。用户停止动画并再次开始操纵拉伸。
- 当用户朝拉伸的相反方向移动手指时,释放拉伸,直到拉伸完全消失,然后开始滚动。
- 当用户在拉伸期间快速滑动时,快速滑动
EdgeEffect以增强拉伸效果。
捕捉动画
当用户捕获正在播放的拉伸动画时,EdgeEffect.getDistance() 会返回 0。此条件表示拉伸必须由轻触动作操控。在大多数容器中,系统会在 onInterceptTouchEvent() 中检测到 catch,如以下代码段所示:
Kotlin
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { ... when (action and MotionEvent.ACTION_MASK) { MotionEvent.ACTION_DOWN -> ... isBeingDragged = EdgeEffectCompat.getDistance(edgeEffectBottom) > 0f || EdgeEffectCompat.getDistance(edgeEffectTop) > 0f ... } return isBeingDragged }
Java
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { ... switch (action & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_DOWN: ... isBeingDragged = EdgeEffectCompat.getDistance(edgeEffectBottom) > 0 || EdgeEffectCompat.getDistance(edgeEffectTop) > 0; ... } }
在前面的示例中,当 mIsBeingDragged 为 true 时,onInterceptTouchEvent() 会返回 true,因此在子级有机会使用该事件之前使用该事件就足够了。
释放滚动效果
请务必在滚动之前释放拉伸效果,以防止拉伸效果应用于滚动内容。以下代码示例应用了此最佳实践:
Kotlin
override fun onTouchEvent(ev: MotionEvent): Boolean { val activePointerIndex = ev.actionIndex when (ev.getActionMasked()) { MotionEvent.ACTION_MOVE -> val x = ev.getX(activePointerIndex) val y = ev.getY(activePointerIndex) var deltaY = y - lastMotionY val pullDistance = deltaY / height val displacement = x / width if (deltaY < 0f && EdgeEffectCompat.getDistance(edgeEffectTop) > 0f) { deltaY -= height * EdgeEffectCompat.onPullDistance(edgeEffectTop, pullDistance, displacement); } if (deltaY > 0f && EdgeEffectCompat.getDistance(edgeEffectBottom) > 0f) { deltaY += height * EdgeEffectCompat.onPullDistance(edgeEffectBottom, -pullDistance, 1 - displacement); } ... }
Java
@Override public boolean onTouchEvent(MotionEvent ev) { final int actionMasked = ev.getActionMasked(); switch (actionMasked) { case MotionEvent.ACTION_MOVE: final float x = ev.getX(activePointerIndex); final float y = ev.getY(activePointerIndex); float deltaY = y - lastMotionY; float pullDistance = deltaY / getHeight(); float displacement = x / getWidth(); if (deltaY < 0 && EdgeEffectCompat.getDistance(edgeEffectTop) > 0) { deltaY -= getHeight() * EdgeEffectCompat.onPullDistance(edgeEffectTop, pullDistance, displacement); } if (deltaY > 0 && EdgeEffectCompat.getDistance(edgeEffectBottom) > 0) { deltaY += getHeight() * EdgeEffectCompat.onPullDistance(edgeEffectBottom, -pullDistance, 1 - displacement); } ...
当用户拖动时,请先消耗 EdgeEffect 拉动距离,然后再将触摸事件传递给嵌套滚动容器或拖动滚动条。在前面的代码示例中,当边缘效果正在显示且可以通过动作释放时,getDistance() 会返回一个正值。当触摸事件释放拉伸效果时,该事件会先由 EdgeEffect 使用,以便在显示其他效果(例如嵌套滚动)之前完全释放拉伸效果。您可以使用 getDistance() 了解释放当前效果所需的拉动距离。
与 onPull() 不同,onPullDistance() 会返回所传递增量的消耗量。从 Android 12 开始,如果 getDistance() 为 0 时向 onPull() 或 onPullDistance() 传递负 deltaDistance 值,拉伸效果不会改变。在 Android 11 及更低版本中,onPull() 允许总距离的负值显示发光效果。
停用滚动回弹
您可以在布局文件中或以编程方式停用滚动回弹。
如需在布局文件中停用此功能,请设置 android:overScrollMode,如以下示例所示:
<MyCustomView android:overScrollMode="never"> ... </MyCustomView>
如需以编程方式停用,请使用如下代码:
Kotlin
customView.overScrollMode = View.OVER_SCROLL_NEVER
Java
customView.setOverScrollMode(View.OVER_SCROLL_NEVER);
其他资源
请参阅以下相关资源: