Escribir comportamiento de deslizamiento vertical flexible

Hola Habr! Mi nombre es Ilya Osintsev, soy desarrollador de Android en Apiqa. Debajo del gato encontrará un ejemplo del uso de ViewDragHelper para crear un componente de interfaz de usuario similar a SwipeDismissBehavior, pero que funciona verticalmente.


Con el advenimiento de Material Design, las aplicaciones se han convertido en elementos más interactivos que responden a las acciones del usuario. No solo ahorran espacio, sino que también introducen micro interacciones divertidas. En varios de nuestros proyectos, decidimos usar pancartas que se mueven verticalmente en la mecánica de deslizar para descartar. Para darle vida a la interfaz, los banners deben tener en cuenta la velocidad del puntero y cambiar la transparencia según la dirección del desplazamiento.


Evaluar la tarea.


Cuenta personalTécnico de gabinete
Cuenta personalTécnico de gabinete

En nuestra aplicación de cuenta personal, el banner actúa como una forma rápida de dejar una apelación al servicio de búsqueda de inquilinos para su hogar. En la aplicación "Técnica de dispositivo", el banner le permite guardar el contexto del trabajo del usuario con la tarea al cambiar de una tarjeta de información a comentarios. En el primer caso, enfatizamos la opcionalidad del servicio PIK-Rent y dejamos que el cliente se sienta como en casa en la aplicación. En otro caso, implementamos deslizar en el indicador para que no se superponga la línea de mensaje entre el despachador y los ejecutores.


Para comenzar, armé una demostración simple basada en SwipeDismissBehavior para aprender cómo funciona y para estimar la escala de los cambios. Intentar especificarlo en el marcado xml produce una excepción cuando se ejecuta:


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 

Los desarrolladores violaron el contrato de comportamiento y olvidaron anular el constructor de la clase del contexto y AttributeSet. Ahora puede usar este comportamiento solo creando una instancia de él en su código, pero incluso así, el comportamiento de la vista fundamentalmente no satisface nuestros requisitos, incluso sin tener en cuenta la dirección horizontal.


SwipeDismissComportamiento fuera de la caja


Había mensajes en los registros de la aplicación de demostración que indicaban que algunos de los eventos táctiles no caían en el controlador.


E / ViewDragHelper: Ignorando pointerId = -1 porque ACTION_DOWN no ​​se recibió para este puntero antes de ACTION_MOVE. Probablemente sucedió porque ViewDragHelper no recibió todos los eventos en la secuencia de eventos.

Como alternativa, había una solución basada en OnTouchListener , que nos priva de la oportunidad de usar OnClickListener, lo que significa que tendremos que describir la ley de movimiento del banner en la actividad. No queremos cambiar los parámetros de movimiento (por ejemplo, sensibilidad) a medida que nos movemos, y usar OnTouchListener aquí parece redundante. Además, en nuestros dos proyectos se colocaron pancartas en CoordinatorLayout.


Si no tiene en cuenta los captadores y definidores de los parámetros opcionales, SwipeDismissBehavior es bastante corto, utiliza ViewDragHelper . Encontré varias publicaciones sobre él en la red y decidí escribir mi propia implementación del componente requerido.


Coordinamos con ViewDragHelper


ViewDragHelper es una clase de utilidad para facilitar el soporte de arrastrar y soltar en el nivel de Vista. Realiza un seguimiento de la posición de los widgets y contiene varias funciones útiles para animarlos a lo largo de uno o dos ejes dentro del ViewGroup principal. Para trabajar, necesita un controlador que implemente ViewDragHelper.Callback . El controlador tiene un método obligatorio, y para que el banner comience a moverse, es suficiente para redefinir un par más. En general, este ayudante es fácil de usar, está disponible en cualquier proyecto, ya que viene con appcompat. Para crear un asistente, se requiere una referencia al CoordinatorLayout principal, por lo que organizaremos una inicialización diferida. En onInterceptTouchEvent y onTouchEvent debemos llamar a los métodos auxiliares correspondientes, el resto de la lógica estará dentro del controlador.


 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 } } 

En el tryCaptureView controlador tryCaptureView requerido tryCaptureView debemos decidir si mover una vista en particular. Para que el ayudante no se pierda los eventos necesarios, admitimos el puntero que se recibió anteriormente. Para obtener la solución más flexible, se introducen tres interfaces adicionales, a través de las cuales puede controlar el diseño del banner en detalle:


  • SideEffect refleja el progreso de SideEffect en las propiedades de vista
  • VerticalClamp diseñado para limitar el movimiento de la vista verticalmente
  • PostAction se llama después de que el usuario detiene el deslizamiento, aquí podemos continuar con el movimiento de la vista.

En cada uno de ellos, se onViewCaptured(View) método onViewCaptured(View) , aquí las implementaciones del cliente pueden extraer los valores iniciales de las propiedades de la vista. El orden de las llamadas a este método no está garantizado.


 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) { // TODO currentPointer = INVALID_POINTER_ID } override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int) = child.left // TODO } 

Aunque nuestra vista no se mueve verticalmente, debemos recordar implementar clampViewPositionHorizontal en el controlador para evitar errores visuales. Una implementación simple devuelve la coordenada izquierda, lo que significa que el widget no se mueve horizontalmente.


En nuestro caso, la llamada al controlador clampViewPositionVertical se delega a la interfaz VerticalClamp . El método de constraint debe devolver una coordenada de altura limitada por la posición de vista máxima y / o mínima. Cuando se alcanza, ViewDragHelper limitará el movimiento. Los upCast(distance, top, height, dy) y downCast tienen la misma firma y devuelven una fracción de la distancia recorrida, teniendo en cuenta la posición inicial de la vista. En el onViewPositionChanged controlador onViewPositionChanged obtenemos el progreso del movimiento y lo SideEffect#apply(View, Float) a SideEffect#apply(View, Float) , en el que puede cambiar la transparencia u otras propiedades de vista según el progreso del gesto. Si la posición actual de la vista es más alta que la inicial, el progreso se transmite con un signo menos.


 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) } 

De forma predeterminada, FractionClamp utiliza FractionClamp , que limita el movimiento de la vista una altura hacia arriba y hacia abajo (los coeficientes se establecen en el constructor), AlphaElevationSideEffect cambia la transparencia y la elevación del banner. Para considerar la tarea completada, debe agregar la capacidad de mover animadamente el banner después de que el usuario lo haya lanzado.


Cuando el usuario suelta la vista, el ayudante recordará la velocidad del puntero y llamará a onViewReleased en el controlador. En él, podemos comenzar la animación de movimiento usando settleCapturedViewAt o smoothSlideViewTo . Según el contrato, después de una llamada exitosa de cualquiera de ellos, se debe llamar a continueSettling en cada fotograma siguiente para que la vista continúe moviéndose. En este caso, resolveCapturedViewAt solo se puede invocar desde el método onViewReleased cuando el indicador interno del asistente mReleaseInProgress se establece en verdadero. Otra diferencia es que smoothSlideViewTo no tiene en cuenta la velocidad del puntero.


 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 } 

Esta lógica está encapsulada en la interfaz PostAction . Los métodos releasedAbove y ReleaseBelow se pueden implementar de modo que cuando se desplaza hacia arriba, el banner continúa moviéndose a la misma velocidad, abandonando la pantalla, y cuando se desplaza hacia abajo, vuelve a su posición original. Si alguno de los métodos devuelve verdadero, la animación se ha iniciado y se agrega un RecursiveSettle a la cola de eventos de visualización, que permanecerá en él hasta que finalice la animación. Por defecto, OriginSettleAction usa cuando la vista en cualquier desplazamiento vuelve al punto de partida. Otra opción: SettleOnTopAction al mover la vista hacia abajo lo devuelve al punto de partida, y cuando se mueve más alto lo saca de la pantalla.


 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) } } 

Si es necesario, puede suscribirse a eventos implementando la interfaz VerticalSwipeBehavior.SwipeListener . Tiene dos métodos simétricos, uno llamado antes del inicio de la animación de movimiento, el otro después de que finaliza. El argumento muestra la dirección y la distancia a la que el usuario lanzó el banner. El resultado resultante satisface nuestros requisitos.


El resultado resultante


Para obtener su resultado, es suficiente definir las propiedades de la siguiente manera:


 val drag = findViewById<View>(R.id.drag) VerticalSwipeBehavior.from(drag).apply { settle = SettleOnTopAction() sideEffect = NegativeFactorFilterSideEffect(AlphaElevationSideEffect()) clamp = BelowFractionalClamp() } 

Por cierto, el ayudante también proporciona otras funciones de control de movimiento. Por ejemplo, utilizando el setMinVelocity(Float) , puede limitar la velocidad mínima para que la vista se mueva. El asistente también admite el reconocimiento de deslizamientos desde los bordes de la pantalla, para esto debe especificarlos en el setEdgeTrackingEnabled(Int) . Debe recordarse que una instancia de ViewDragHelper puede controlar el movimiento de una sola vista y tiene en cuenta solo un puntero.


Sacar conclusiones


Mi experiencia es que ViewDragHelper facilita la creación de arrastrar y soltar o mover paneles en una aplicación. Helper es fácil de usar en Comportamiento o en ViewGroup anulado. Tiene varios métodos útiles para animar la vista y controla su movimiento. Estudiar la implementación interna de los componentes de Material Design es una buena experiencia en la carrera de un desarrollador de Android. Tales tareas de los diseñadores me motivan a explorar nuevos enfoques para construir una interfaz de aplicación y compartir conocimientos con colegas y la comunidad.


Puede usar la biblioteca resultante en sus proyectos. Para habilitarlo, especifique la dependencia en el archivo build.gradle


 dependencies { implementation 'io.apiqa.android:verticalswipebehavior:1.0.0' } 

Para una vista adecuada dentro de CoordinatorLayout, especifique la propiedad de la app:layout_behavior="io.apiqa.android.verticalswipe.VerticalSwipeBehavior" en el marcado. Puede colocar el banner dentro del padre utilizando sangrías. Al coordinar las implementaciones de SideEffect , VerticalClamp y PostAction, puede lograr el comportamiento de banner que necesita. En el repositorio , hay disponibles versiones de trabajo de cada uno de ellos.


Feliz año nuevo

Source: https://habr.com/ru/post/481910/


All Articles