哈Ha! 我叫Ilya Osintsev,我是Apiqa的一名Android开发人员。 在猫下,您将找到一个使用ViewDragHelper创建类似于SwipeDismissBehavior的用户界面组件的示例,但它是垂直工作的。
随着Material Design的出现,应用程序已成为响应用户操作的更具交互性的元素。 它们不仅节省空间,而且引入了有趣的微交互。 在我们的几个项目中,我们决定在滑动到关闭机械装置上使用垂直移动的横幅。 为了使界面更生动,横幅必须考虑指针的速度,并根据偏移的方向更改透明度。
评估任务
在我们的“ 个人帐户”应用程序中,横幅广告可以作为一种快速吸引住户搜索服务的方法。 在“信息柜设备”应用程序中,从信息卡切换到注释时,横幅广告可以保存用户处理任务的上下文。 在第一种情况下,我们强调PIK-Rent服务的可选性,并让客户在应用程序中有宾至如归的感觉。 在另一种情况下,我们在提示处实施滑动,以使其不与调度程序和执行程序之间的消息行重叠。
首先,我基于SwipeDismissBehavior编写了一个简单的演示,以了解其工作原理并估算更改的规模。 尝试在xml标记中指定它会在执行时引发异常:
E/AndroidRuntime: FATAL EXCEPTION: main Process: io.apiqa.android.example, PID: 1024 android.view.InflateException: Binary XML file line #115: Could not inflate Behavior subclass com.google.android.material.behavior.SwipeDismissBehavior
开发人员违反了行为合同,忘记从上下文和AttributeSet中重写类构造函数。 现在,您只能通过在代码中创建一个实例来使用此行为,但是即使那样,即使不考虑水平方向,视图行为也根本无法满足我们的要求。

演示应用程序的日志中有消息,指出某些触摸事件未落入处理程序中。
E / ViewDragHelper:因为在ACTION_MOVE之前未收到此指针的ACTION_DOWN,所以忽略了pointerId = -1。 发生这种情况的原因可能是ViewDragHelper没有收到事件流中的所有事件。
作为替代方案,有一个基于OnTouchListener
的解决方案,它使我们失去了使用OnClickListener的机会,这意味着我们将不得不描述横幅在活动中的运动规律。 我们不想在移动时更改移动参数(例如,灵敏度),这里使用OnTouchListener似乎是多余的。 此外,在我们的两个项目中,横幅都放置在CoordinatorLayout中。
如果不考虑可选参数的获取器和设置器,则SwipeDismissBehavior本身就很短,它使用ViewDragHelper
。 我在网络上找到了几 本有关他的出版物 ,并决定编写所需组件的我自己的实现。
我们与ViewDragHelper协调
ViewDragHelper是一个实用程序类,可促进在View级别上的拖放支持。 它跟踪窗口小部件的位置,并包含几个有用的函数,这些函数可沿父视图组内的一两个轴对它们进行动画处理。 要工作,他需要一个实现ViewDragHelper.Callback的处理程序。 该处理程序具有一种强制性方法,并且对于横幅开始移动而言,足以重新定义几个。 通常,此辅助程序易于使用,它随appcompat一起提供,可在任何项目中使用。 要创建一个助手,需要引用父CoordinatorLayout,因此我们将组织延迟初始化。 在onInterceptTouchEvent
和onTouchEvent
我们必须调用相应的帮助器方法,其余逻辑将在处理程序内部。
class VerticalSwipeBehavior<V: View>: CoordinatorLayout.Behavior<V> { companion object { @Suppress("UNCHECKED_CAST") fun <V: View> from(v: V): VerticalSwipeBehavior<V> { val lp = v.layoutParams require(lp is CoordinatorLayout.LayoutParams) val behavior = lp.behavior requireNotNull(behavior) require(behavior is VerticalSwipeBehavior) return behavior as VerticalSwipeBehavior<V> } } @Suppress("unused") constructor() : super() @Suppress("unused") constructor(context: Context, attrs: AttributeSet) : super(context, attrs) private var dragHelper: ViewDragHelper? = null private var interceptingEvents = false override fun onInterceptTouchEvent(parent: CoordinatorLayout, child: V, ev: MotionEvent): Boolean { var isIntercept = interceptingEvents when (ev.actionMasked) { MotionEvent.ACTION_DOWN -> { isIntercept = parent.isPointInChildBounds(child, ev.x.toInt(), ev.y.toInt()) interceptingEvents = isIntercept } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { interceptingEvents = false } } return if (isIntercept) { helper(parent).shouldInterceptTouchEvent(ev) } else false } override fun onTouchEvent(parent: CoordinatorLayout, child: V, ev: MotionEvent): Boolean { val helper = helper(parent) val isViewUnder = helper.isViewUnder(child, ev.x.toInt(), ev.y.toInt()) if (helper.capturedView == child || isViewUnder ) { helper.processTouchEvent(ev) return true } else { return false } } private fun helper(parent: ViewGroup): ViewDragHelper { var h = dragHelper if (h == null) { h = ViewDragHelper.create(parent, callback) dragHelper = h return h } return h } }
在必需的tryCaptureView
处理程序tryCaptureView
我们必须决定是否移动特定视图。 为了使助手不会错过必要的事件,我们接受了之前收到的指针。 为了获得最灵活的解决方案,引入了三个附加界面,通过它们可以详细控制横幅设计:
SideEffect
反映了视图属性中的SideEffect
进度VerticalClamp
旨在限制垂直方向上的视图移动- 用户停止滑动
PostAction
调用PostAction
,在这里我们可以继续视图移动。
在每个方法中,都onViewCaptured(View)
了onViewCaptured(View)
方法,在此客户端实现可以提取视图属性的初始值。 不能保证调用此方法的顺序。
var sideEffect: SideEffect = AlphaElevationSideEffect() var clamp: VerticalClamp = FractionConstraintWithTopMargin(1f, 1f) var settle: PostAction = OriginSettleAction() private val callback = object: ViewDragHelper.Callback() { private val INVALID_POINTER_ID = -1 private var currentPointer = INVALID_POINTER_ID private var originTop: Int = 0 override fun tryCaptureView(child: View, pointerId: Int): Boolean { return currentPointer == INVALID_POINTER_ID || pointerId == currentPointer } override fun onViewCaptured(child: View, activePointerId: Int) { originTop = child.top currentPointer = activePointerId sideEffect.onViewCaptured(child) settle.onViewCaptured(child) clamp.onViewCaptured(child.top) } override fun onViewReleased(child: View, xvel: Float, yvel: Float) {
尽管我们的视图不是垂直移动的,但是我们必须记住在处理程序中实现clampViewPositionHorizontal
,以避免出现视觉错误。 一个简单的实现返回左坐标,这意味着小部件不会水平移动。
在我们的例子中,对clampViewPositionVertical
处理程序的调用委托给VerticalClamp接口。 constraint
方法应返回受最大和/或最小视图位置限制的高度坐标。 达到时,ViewDragHelper将限制移动。 upCast(distance, top, height, dy)
和downCast
具有相同的签名,并考虑视图的初始位置,返回行进距离的一小部分。 在onViewPositionChanged
处理程序onViewPositionChanged
我们获取移动的进度,并将其传递给SideEffect#apply(View, Float)
,在其中您可以根据手势的进度更改透明度或其他视图属性。 如果视图的当前位置高于初始位置,则将以减号传输进度。
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { return clamp.constraint(child.height, top, dy) } override fun onViewPositionChanged(child: View, left: Int, top: Int, dx: Int, dy: Int) { val factor = if (top < originTop) { val diff = originTop - top -clamp.bottomCast(diff, top, child.height, dy) } else { val diff = top - originTop clamp.topCast(diff, top, child.height, dy) } sideEffect.apply(child, factor) }
默认情况下,使用FractionClamp
,它将视图的上下移动限制为一个高度(在构造函数中设置了系数), AlphaElevationSideEffect
更改了横幅的透明度和高度。 要考虑任务已完成,您必须添加在用户释放横幅后以动画方式移动横幅的功能。
当用户释放视图时,帮助程序将记住指针的速度并在处理程序上调用onViewReleased
。 在其中,我们可以使用settleCapturedViewAt
或smoothSlideViewTo
启动运动动画。 根据合同,在成功调用其中任何一个之后,应在每个下一帧上调用continueSettling,以使视图继续移动。 在这种情况下,仅当mReleaseInProgress 帮助器的内部标志设置为true时,才能从onViewReleased方法中调用resolveCapturedViewAt。 另一个区别是smoothSlideViewTo不考虑指针的速度。
override fun onViewReleased(child: View, xvel: Float, yvel: Float) { val diff = child.top - originTop if (abs(yvel) > 0) { val settled = dragHelper?.let { if (diff > 0) { settle.releasedBelow(it, diff, child) } else { settle.releasedAbove(it, diff, child) } } ?: false if (settled) { listener?.onPreSettled(diff) child.postOnAnimation(RecursiveSettle(child, diff)) } } currentPointer = INVALID_POINTER_ID }
该逻辑封装在PostAction
接口中。 可以实现releasedAbove
和releasedBelow
方法,以便在向上移动时,横幅继续以相同的速度移动,离开屏幕,在向下移动时,横幅返回其原始位置。 如果任何方法返回true,则表示动画已触发,并且RecursiveSettle已添加到视图事件队列,该事件将一直保留在其中,直到动画结束。 默认情况下,当任意偏移量的视图返回到起点时, OriginSettleAction
使用OriginSettleAction
。 另一个选项- SettleOnTopAction
移动视图时, SettleOnTopAction
将其返回到起点,而向上移动时,则将您带出屏幕。
class SettleOnTopAction: PostAction { private var originTop: Int = -1 override fun onViewCaptured(child: View) { originTop = child.top } override fun releasedAbove(helper: ViewDragHelper, child: View): Boolean { return helper.settleCapturedViewAt(child.left, originTop) } override fun releasedBelow(helper: ViewDragHelper, child: View): Boolean { return helper.settleCapturedViewAt(child.left, -child.height) } }
如有必要,您可以通过实现VerticalSwipeBehavior.SwipeListener
接口来订阅事件。 它有两种对称方法,一种在移动动画开始之前调用,另一种在结束动画之后调用。 自变量显示用户释放横幅的方向和距离。 结果满足我们的要求。

要获得其结果,只需按如下所示定义属性即可:
val drag = findViewById<View>(R.id.drag) VerticalSwipeBehavior.from(drag).apply { settle = SettleOnTopAction() sideEffect = NegativeFactorFilterSideEffect(AlphaElevationSideEffect()) clamp = BelowFractionalClamp() }
顺便说一句,助手还提供其他运动控制功能。 例如,使用setMinVelocity(Float)
方法,可以限制视图移动的最小速度。 该帮助程序还支持从屏幕边界识别滑动,为此,您需要在setEdgeTrackingEnabled(Int)
方法中指定它们。 应当记住,ViewDragHelper的一个实例只能控制一个视图的移动,并且只考虑一个指针。
得出结论
我的经验是,使用ViewDragHelper可以轻松地在应用程序中创建拖放或移动面板。 Helper在“行为”或覆盖的ViewGroup中易于使用。 它有几种有用的方法来设置视图动画并控制其移动。 在Android开发人员的职业生涯中,学习Material Design组件的内部实现是一个很好的经验。 设计师的这些任务激发了我探索构建应用程序界面并与同事和社区共享知识的新方法。
您可以在项目中使用结果库。 要启用它,请在build.gradle
文件中指定依赖build.gradle
dependencies { implementation 'io.apiqa.android:verticalswipebehavior:1.0.0' }
为了在CoordinatorLayout内部获得合适的视图,请在标记中指定app:layout_behavior="io.apiqa.android.verticalswipe.VerticalSwipeBehavior"
属性app:layout_behavior="io.apiqa.android.verticalswipe.VerticalSwipeBehavior"
。 您可以使用缩进将横幅广告放置在父级中。 通过协调SideEffect , VerticalClamp和PostAction实现,可以实现所需的横幅行为。 在存储库中 ,可以使用它们各自的工作版本。
新年快乐!