Écriture de VerticalSwipeBehavior flexible

Bonjour, Habr! Je m'appelle Ilya Osintsev, je suis développeur Android chez Apiqa. Sous le chat, vous trouverez un exemple d'utilisation de ViewDragHelper pour créer un composant d'interface utilisateur similaire à SwipeDismissBehavior, mais fonctionnant verticalement.


Avec l'avènement de Material Design, les applications sont devenues des éléments plus interactifs qui répondent aux actions des utilisateurs. Ils économisent non seulement de l'espace, mais introduisent également des micro-interactions amusantes. Dans plusieurs de nos projets, nous avons décidé d'utiliser des bannières se déplaçant verticalement sur les mécanismes de glisser-déplacer. Pour donner de la vivacité à l'interface, les bannières doivent prendre en compte la vitesse du pointeur et changer la transparence en fonction de la direction du décalage.


Évaluez la tâche


Compte personnelTechnicien de Cabinet
Compte personnelTechnicien de Cabinet

Dans notre application de compte personnel, la bannière agit comme un moyen rapide de faire appel au service de recherche de locataires pour votre maison. Dans l'application «Cabinet Appliance», la bannière vous permet de sauvegarder le contexte du travail de l'utilisateur avec la tâche lors du passage d'une fiche d'information aux commentaires. Dans le premier cas, nous soulignons le caractère facultatif du service PIK-Rent et laissons le client se sentir chez lui dans l'application. Dans un autre cas, nous implémentons le balayage à l'invite afin qu'il ne chevauche pas la ligne de message entre le répartiteur et les exécuteurs.


Pour commencer, j'ai mis en place une démo simple basée sur SwipeDismissBehavior pour apprendre comment cela fonctionne et estimer l'ampleur des changements. Tenter de le spécifier dans le balisage xml lève une exception lors de son exécution:


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 

Les développeurs ont violé le contrat de comportement et ont oublié de remplacer le constructeur de classe à partir du contexte et de AttributeSet. Vous pouvez désormais utiliser ce comportement uniquement en en créant une instance dans votre code, mais même dans ce cas, le comportement de la vue ne satisfait pas fondamentalement nos exigences, même sans prendre en compte la direction horizontale.


SwipeDismissBehavior hors de la boîte


Il y avait des messages dans les journaux de l'application de démonstration que certains des événements tactiles n'étaient pas tombés dans le gestionnaire.


E / ViewDragHelper: Ignorer pointerId = -1 car ACTION_DOWN n'a pas été reçu pour ce pointeur avant ACTION_MOVE. Cela s'est probablement produit car ViewDragHelper n'a pas reçu tous les événements du flux d'événements.

Comme alternative, il y avait une solution basée sur OnTouchListener , elle nous prive de la possibilité d'utiliser OnClickListener, ce qui signifie que nous devrons décrire la loi de mouvement de la bannière en activité. Nous ne voulons pas modifier les paramètres de mouvement (par exemple, la sensibilité) lorsque nous nous déplaçons, et l'utilisation d'OnTouchListener semble ici redondante. De plus, dans nos deux projets, des bannières ont été placées dans CoordinatorLayout.


Si vous ne prenez pas en compte les getters et setters des paramètres optionnels, le SwipeDismissBehavior lui-même est assez court, il utilise ViewDragHelper . J'ai trouvé plusieurs publications à son sujet sur le réseau et j'ai décidé d'écrire ma propre implémentation du composant requis.


Nous coordonnons avec ViewDragHelper


ViewDragHelper est une classe utilitaire pour faciliter la prise en charge du glisser-déposer au niveau de la vue. Il suit la position des widgets et contient plusieurs fonctions utiles pour les animer le long d'un ou deux axes à l'intérieur du ViewGroup parent. Pour fonctionner, il a besoin d'un gestionnaire qui implémente ViewDragHelper.Callback . Le gestionnaire a une méthode obligatoire, et pour que la bannière commence à bouger, il suffit d'en redéfinir quelques autres. En général, cet assistant est facile à utiliser, il est disponible dans n'importe quel projet, car il est fourni avec appcompat. Pour créer un assistant, une référence au Parent Coordinator Layout est requise, nous allons donc organiser l'initialisation paresseuse. Dans onInterceptTouchEvent et onTouchEvent nous devons appeler les méthodes d'assistance correspondantes, le reste de la logique sera à l'intérieur du gestionnaire.


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

Dans la tryCaptureView gestionnaire tryCaptureView requise tryCaptureView nous devons décider de déplacer ou non une vue particulière. Pour que l'assistant ne manque pas les événements nécessaires, nous admettons le pointeur reçu précédemment. Pour obtenir la solution la plus flexible, trois interfaces supplémentaires sont introduites, à travers lesquelles vous pouvez contrôler la conception de la bannière en détail:


  • SideEffect reflète la progression du SideEffect dans les propriétés de la vue
  • VerticalClamp conçu pour limiter le mouvement de la vue verticalement
  • PostAction est appelée après que l'utilisateur a arrêté le balayage, ici nous pouvons continuer le mouvement de la vue.

Dans chacun d'eux, la onViewCaptured(View) est onViewCaptured(View) , ici les implémentations client peuvent extraire les valeurs initiales des propriétés de la vue. L'ordre des appels à cette méthode n'est pas garanti.


 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 } 

Bien que notre vue ne se déplace pas verticalement, nous devons nous rappeler d'implémenter clampViewPositionHorizontal dans le gestionnaire pour éviter les bugs visuels. Une implémentation simple renvoie la coordonnée gauche, ce qui signifie que le widget ne se déplace pas horizontalement.


Dans notre cas, l'appel au gestionnaire clampViewPositionVertical est délégué à l'interface VerticalClamp . La méthode de constraint doit renvoyer une coordonnée de hauteur limitée par la position de vue maximale et / ou minimale. Lorsqu'il est atteint, ViewDragHelper limitera le mouvement. Les upCast(distance, top, height, dy) et downCast ont la même signature et renvoient une fraction de la distance parcourue, en tenant compte de la position initiale de la vue. Dans la onViewPositionChanged gestionnaire onViewPositionChanged nous obtenons la progression du déplacement et la transmettons à SideEffect#apply(View, Float) , dans laquelle vous pouvez modifier la transparence ou d'autres propriétés d'affichage en fonction de la progression du geste. Si la position actuelle de la vue est supérieure à la position initiale, la progression est transmise avec un signe moins.


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

Par défaut, FractionClamp utilisé, ce qui limite le mouvement de la vue d'une hauteur vers le haut et vers le bas (les coefficients sont définis dans le constructeur), AlphaElevationSideEffect modifie la transparence et l'élévation de la bannière. Pour considérer que la tâche est terminée, vous devez ajouter la possibilité d'animer déplacer la bannière après que l'utilisateur l'a libérée.


Lorsque l'utilisateur relâche la vue, l'assistant se souvient de la vitesse du pointeur et appelle onViewReleased sur le gestionnaire. Dans celui-ci, nous pouvons démarrer l'animation de mouvement en utilisant settleCapturedViewAt ou smoothSlideViewTo . Selon le contrat, après un appel réussi de l'un d'eux, continueSettling doit être appelé sur chaque trame suivante afin que la vue continue de bouger. Dans ce cas, settCapturedViewAt ne peut être appelé qu'à partir de la méthode onViewReleased lorsque l'indicateur interne de l' aide mReleaseInProgress est défini sur true. Une autre différence est que smoothSlideViewTo ne prend pas en compte la vitesse du pointeur.


 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 } 

Cette logique est encapsulée dans l'interface PostAction . Les méthodes releasedAbove et releaseBelow peuvent être implémentées de sorte que lorsqu'elle est déplacée vers le haut, la bannière continue de se déplacer à la même vitesse, en quittant l'écran, et lorsqu'elle est déplacée vers le bas, elle revient à sa position d'origine. Si l'une des méthodes retourne true, alors l'animation a été déclenchée et une RecursiveSettle est ajoutée à la file d'attente des événements d'affichage, qui y restera jusqu'à la fin de l'animation. Par défaut, OriginSettleAction utilisé lorsque la vue à n'importe quel décalage revient au point de départ. Une autre option - SettleOnTopAction lorsque vous déplacez la vue vers le bas la ramène au point de départ, et lorsque vous SettleOnTopAction plus haut, elle vous fait quitter l'écran.


 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 nécessaire, vous pouvez vous abonner aux événements en implémentant l'interface VerticalSwipeBehavior.SwipeListener . Il a deux méthodes symétriques, l'une appelée avant le début de l'animation de déplacement, l'autre après sa fin. L'argument indique la direction et la distance à laquelle l'utilisateur a relâché la bannière. Le résultat obtenu satisfait nos exigences.


Le résultat résultant


Pour obtenir son résultat, il suffit de définir les propriétés comme suit:


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

Soit dit en passant, l'aide fournit également d'autres fonctionnalités de contrôle de mouvement. Par exemple, en utilisant la setMinVelocity(Float) , vous pouvez limiter la vitesse minimale de déplacement de la vue. L'assistant prend également en charge la reconnaissance des balayages à partir des bordures de l'écran, pour cela, vous devez les spécifier dans la setEdgeTrackingEnabled(Int) . Il ne faut pas oublier qu'une seule instance de ViewDragHelper peut contrôler le mouvement d'une seule vue et ne prend en compte qu'un seul pointeur.


Tirer des conclusions


D'après mon expérience, ViewDragHelper facilite la création de glisser-déposer ou le déplacement de panneaux dans une application. Helper est facile à utiliser dans Behavior ou dans ViewGroup substitué. Il a plusieurs méthodes utiles pour animer la vue et contrôle leur mouvement. L'étude de l'implémentation interne des composants Material Design est une bonne expérience dans la carrière d'un développeur Android. Ces tâches de concepteurs me motivent à explorer de nouvelles approches pour créer une interface d'application et partager des connaissances avec des collègues et la communauté.


Vous pouvez utiliser la bibliothèque résultante dans vos projets. Pour l'activer, spécifiez la dépendance dans le fichier build.gradle


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

Pour une vue appropriée à l'intérieur de CoordinatorLayout, spécifiez la propriété de l' app:layout_behavior="io.apiqa.android.verticalswipe.VerticalSwipeBehavior" dans le balisage. Vous pouvez positionner la bannière dans le parent à l'aide de retraits. En coordonnant les implémentations SideEffect , VerticalClamp et PostAction, vous pouvez obtenir le comportement de bannière dont vous avez besoin. Des versions de travail de chacun d'entre eux sont disponibles dans le référentiel .


Bonne année!

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


All Articles