Olá Habr! Meu nome é Ilya Osintsev, sou desenvolvedor Android da Apiqa. Abaixo do gato, você encontrará um exemplo do uso do ViewDragHelper para criar um componente da interface do usuário semelhante ao SwipeDismissBehavior, mas trabalhando na vertical.
Com o advento do Design de materiais, os aplicativos se tornaram elementos mais interativos que respondem às ações do usuário. Eles não apenas economizam espaço, mas também introduzem micro-interações divertidas. Em vários de nossos projetos, decidimos usar banners em movimento vertical na mecânica deslizar para dispensar. Para dar vida à interface, os banners devem levar em consideração a velocidade do ponteiro e alterar a transparência, dependendo da direção do deslocamento.
Avalie a tarefa
Em nosso aplicativo de conta pessoal, o banner funciona como uma maneira rápida de deixar um apelo ao serviço de pesquisa de inquilinos da sua casa. No aplicativo "Cabinet Appliance", o banner permite salvar o contexto do trabalho do usuário com a tarefa ao mudar de um cartão de informações para comentários. No primeiro caso, enfatizamos a opcionalidade do serviço PIK-Rent e deixamos o cliente se sentir em casa no aplicativo. Em outro caso, implementamos furto no prompt para que não se sobreponha à linha de mensagem entre o expedidor e os executores.
Para começar, montei uma demonstração simples baseada no SwipeDismissBehavior para aprender como funciona e estimar a escala das alterações. Tentar especificá-lo na marcação xml lança uma exceção quando executado:
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
Os desenvolvedores violaram o contrato de comportamento e esqueceram de substituir o construtor de classe do contexto e do AttributeSet. Agora você pode usar esse comportamento apenas criando uma instância dele no seu código, mas mesmo assim, o comportamento da visualização não satisfaz fundamentalmente nossos requisitos, mesmo sem levar em consideração a direção horizontal.

Havia mensagens nos logs do aplicativo de demonstração de que alguns dos eventos de toque não caíram no manipulador.
E / ViewDragHelper: Ignorando pointerId = -1 porque ACTION_DOWN não foi recebido para esse ponteiro antes de ACTION_MOVE. Provavelmente aconteceu porque o ViewDragHelper não recebeu todos os eventos no fluxo de eventos.
Como alternativa, havia uma solução baseada no OnTouchListener
, que nos priva da oportunidade de usar o OnClickListener, o que significa que teremos que descrever a lei do movimento do banner em atividade. Não queremos alterar os parâmetros de movimento (por exemplo, sensibilidade) à medida que avançamos, e usar o OnTouchListener aqui parece redundante. Além disso, em ambos os nossos projetos, banners foram colocados no CoordinatorLayout.
Se você não levar em consideração os getters e setters dos parâmetros opcionais, o SwipeDismissBehavior em si é bastante curto, ele usa o ViewDragHelper
. Encontrei várias publicações sobre ele na rede e decidi escrever minha própria implementação do componente necessário.
Nós coordenamos com ViewDragHelper
ViewDragHelper é uma classe de utilitário para facilitar o suporte ao arrastar e soltar no nível de exibição. Ele rastreia a posição dos widgets e contém várias funções úteis para animá-los ao longo de um ou dois eixos dentro do ViewGroup pai. Para trabalhar, ele precisa de um manipulador que implemente o ViewDragHelper.Callback . O manipulador tem um método obrigatório e, para que o banner comece a se mover, basta redefinir mais alguns. Em geral, esse auxiliar é fácil de usar, está disponível em qualquer projeto, pois é fornecido com o appcompat. Para criar um auxiliar, é necessária uma referência ao CoordinatorLayout pai, para que organizemos a inicialização lenta. Em onInterceptTouchEvent
e onTouchEvent
devemos chamar os métodos auxiliares correspondentes, o restante da lógica estará dentro do manipulador.
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 } }
No tryCaptureView
manipulador tryCaptureView
necessário tryCaptureView
devemos decidir se devemos mover uma exibição específica. Para que o ajudante não perca os eventos necessários, admitimos o ponteiro que foi recebido anteriormente. Para obter a solução mais flexível, três interfaces adicionais são introduzidas, através das quais você pode controlar o design do banner em detalhes:
SideEffect
reflete o progresso do SideEffect
nas propriedades da exibiçãoVerticalClamp
projetado para limitar o movimento da vista verticalmentePostAction
é chamado depois que o usuário interrompe o furto, aqui podemos continuar o movimento da visualização.
Em cada um deles, o onViewCaptured(View)
é onViewCaptured(View)
, aqui as implementações do cliente podem extrair os valores iniciais das propriedades da exibição. A ordem das chamadas para esse método não é garantida.
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) {
Embora nossa visão não se mova verticalmente, devemos lembrar de implementar clampViewPositionHorizontal
no manipulador para evitar erros visuais. Uma implementação simples retorna a coordenada esquerda, o que significa que o widget não se move horizontalmente.
No nosso caso, a chamada para o manipulador clampViewPositionVertical
é delegada à interface VerticalClamp . O método de constraint
deve retornar uma coordenada de altura limitada pela posição de visualização máxima e / ou mínima. Quando atingido, o ViewDragHelper limitará o movimento. Os upCast(distance, top, height, dy)
e downCast
têm a mesma assinatura e retornam uma fração da distância percorrida, levando em consideração a posição inicial da visualização. No onViewPositionChanged
manipulador onViewPositionChanged
obtemos o progresso da movimentação e passamos para o SideEffect#apply(View, Float)
, no qual você pode alterar a transparência ou outras propriedades da visualização, dependendo do andamento do gesto. Se a posição atual da vista for maior que a inicial, o progresso será transmitido com um sinal de 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) }
Por padrão, FractionClamp
usado, o que limita o movimento da vista uma altura para cima e para baixo (os coeficientes são definidos no construtor), AlphaElevationSideEffect
altera a transparência e a elevação do banner. Para considerar a tarefa concluída, você deve adicionar a capacidade de mover animado o banner depois que o usuário o liberou.
Quando o usuário libera a exibição, o ajudante se lembra da velocidade do ponteiro e chama onViewReleased
no manipulador. Nele, podemos iniciar a animação do movimento usando settleCapturedViewAt
ou smoothSlideViewTo
. De acordo com o contrato, após uma chamada bem-sucedida de qualquer um deles, continueSettling
deve ser chamado em cada próximo quadro para que a visualização continue a se mover. Nesse caso, settleCapturedViewAt só pode ser chamado a partir do método onViewReleased quando o sinalizador interno do auxiliar mReleaseInProgress estiver definido como true. Outra diferença é que smoothSlideViewTo não leva em consideração a velocidade do ponteiro.
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 }
Essa lógica é encapsulada na interface PostAction
. Os métodos releasedBelow
e releaseBelow podem ser implementados para que, quando mudado para cima, o banner continue a se mover na mesma velocidade, deixando a tela e, quando mudado para baixo, retorne à sua posição original. Se algum dos métodos retornar true, a animação foi acionada e um RecursiveSettle será adicionado à fila de eventos de exibição, que permanecerá nela até a animação terminar. Por padrão, OriginSettleAction
usado quando a exibição em qualquer deslocamento retorna ao ponto inicial. Outra opção - SettleOnTopAction
ao mover a visualização para baixo, retorna ao ponto inicial e, quando se move mais alto, tira você da tela.
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) } }
Se necessário, você pode se inscrever em eventos implementando a interface VerticalSwipeBehavior.SwipeListener
. Ele tem dois métodos simétricos, um chamado antes do início da animação de movimento e o outro após o término. O argumento mostra a direção e a distância na qual o usuário liberou o banner. O resultado resultante satisfaz nossos requisitos.

Para obter seu resultado, basta definir as propriedades da seguinte maneira:
val drag = findViewById<View>(R.id.drag) VerticalSwipeBehavior.from(drag).apply { settle = SettleOnTopAction() sideEffect = NegativeFactorFilterSideEffect(AlphaElevationSideEffect()) clamp = BelowFractionalClamp() }
A propósito, o auxiliar também fornece outros recursos de controle de movimento. Por exemplo, usando o método setMinVelocity(Float)
, você pode limitar a velocidade mínima para a exibição se mover. O auxiliar também suporta o reconhecimento de furtos a partir das bordas da tela, para isso é necessário especificá-los no método setEdgeTrackingEnabled(Int)
. Deve-se lembrar que uma instância do ViewDragHelper pode controlar o movimento de apenas uma visualização e leva em consideração apenas um ponteiro.
Tirar conclusões
Minha experiência é que o ViewDragHelper facilita a criação de arrastar e soltar ou mover painéis em um aplicativo. O Helper é fácil de usar no Comportamento ou no ViewGroup substituído. Possui vários métodos úteis para animar a vista e controla seus movimentos. Estudar a implementação interna dos componentes do Material Design é uma boa experiência na carreira de um desenvolvedor Android. Essas tarefas dos designers me motivam a explorar novas abordagens para criar uma interface de aplicativo e compartilhar conhecimento com colegas e a comunidade.
Você pode usar a biblioteca resultante em seus projetos. Para ativá-lo, especifique a dependência no arquivo build.gradle
dependencies { implementation 'io.apiqa.android:verticalswipebehavior:1.0.0' }
Para uma visualização adequada dentro do CoordinatorLayout, especifique a propriedade do app:layout_behavior="io.apiqa.android.verticalswipe.VerticalSwipeBehavior"
na marcação. Você pode posicionar o banner no pai usando recuos. Ao coordenar as implementações SideEffect , VerticalClamp e PostAction, você pode obter o comportamento do banner necessário. No repositório , versões de trabalho de cada um deles estão disponíveis.
Feliz Ano Novo!