Designing a flick dismissible image viewer

While working on Dank, my primary goal was to ensure that user generated content on Reddit receive as much attention in the app as possible, while letting the UI take a back-seat. As part of this experience, all images and videos in Dank are flick-dismissible so that the user can browse through high quality cat photos as fast as possible.

Because flick dismissible images are not a new idea, I was hoping that some kind soul on the internet would have already solved this problem as a reusable library. I found a few, but they weren’t simple enough. So I decided to do what we developers naturally do — write my own implementation.

The result was Flick, a tiny library for flick dismissing images (or anything actually). I’m putting it up on Github so that anyone can use it: https://github.com/saket/flick.

<me.saket.flick.FlickDismissLayout
  android:layout_width="match_parent"
  android:layout_height="wrap_content">

  <ImageView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"/>
</me.saket.flick.FlickDismissLayout>

Flick’s detailed usage instructions can be found on Github, but it’s easy to drop into existing projects. If you’re interested in learning how the gesture was written, you may continue reading.

Let’s start with the basics. All touch events travel through the View hierarchy from top to bottom and back to top. That is, they start at the root layout and travel through the tree of Views until they’re consumed. If all leaves are reached without any consumers, the events travel back to the root. At any point during this travel, ViewGroups are allowed to “watch” these touch events as they travel to their child Views and intercept them if they want to.

Flick uses this behaviour to “watch” scrolls. Vertical scrolls are intercepted, horizontal scrolls (on a ViewPager for instance) are allowed to pass. If a scroll is registered, all subsequent touch events until the next finger release are intercepted, blocking the content Views from receiving them.

fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
  val intercepted = flickGestureListener.onTouch(this, ev)
  return intercepted || super.onInterceptTouchEvent(ev)
}

For every movement registered, the content image is moved with the distance moved.

override fun onTouch(view, event): Boolean {
  val touchX = event.rawX
  val touchY = event.rawY

  val deltaX = touchX - lastTouchX
  val deltaY = touchY - lastTouchY

  lastTouchX = touchX
  lastTouchY = touchY

  when (event.action) {
    ACTION_MOVE -> {
      view.translationX += deltaX
      view.translationY += deltaY
    }
  }
}

To make the gesture feel more fluid — as if the user is actually flicking photos in the physical world, Flick rotates the image in the direction of the gesture.

override fun onTouch(view, event): Boolean {
  when (event.action) {
    ACTION_DOWN -> {
      touchStartedOnLeftSide = touchX < view.width / 2
    }

    ACTION_MOVE -> {
      val moveRatioDelta = deltaY / view.height * (if (touchStartedOnLeftSide) -20F else 20F)
      view.pivotY = 0F
      view.rotation = view.rotation + moveRatioDelta
    }
  }
}

Rotation of images introduces an interesting problem — jagged and pixelated edges. When a Bitmap is scaled or rotated, Android’s renderer turns on Bilinear filtering, which works by sampling 4 pixels from the texture. When sampling the edges, there’s nothing to sample outside of the bitmap so they get rendered as jagged edges.

A quick and dirty solution is to add 1px borders to the image. This causes Bilinear filtering to sample transparent pixels and average them with the actual texture, creating an illusion of anti-aliasing. Drawing the image directly on the canvas using drawRect() with a BitmapShader might produce a better result, but 1px borders are good enough for now.

Picasso.get()
    .load(...)
    .transform(PaddingTransformation(px = 1F, color = Color.TRANSPARENT))
    .into(imageView)

Source: PaddingTransformation.kt. Thanks to Romain Guy for this tip.

When the finger is released, Flick checks if the scroll distance was sufficient to dismiss the image. If it was sufficient, the image is animated out of the display or animated back into its original position otherwise.

when (event.action) {
  ACTION_DOWN -> {
    downY = event.rawY
  }

  ACTION_UP -> {
    val distanceYAbs = Math.abs(event.rawY - downY)
    val thresholdDistanceY = contentHeight() * flickThreshold
    val eligibleForDismiss = distanceYAbs > thresholdDistanceY

    if (eligibleForDismiss) {
      animateDismissal(view)

    } else {
      animateBackToPosition()
    }
  }
}

Registering discrete movements isn’t enough for making the gesture feel right. Apart from calculating the distance moved between touch-down and touch-up, the velocity of the scroll is also useful. If the distance doesn’t cross the threshold distance but the velocity was fast enough, it is treated as a fling.

when (event.action) {
  ACTION_DOWN -> {
    velocityTracker = VelocityTracker.obtain()
  }

  ACTION_UP -> {
    velocityTracker.computeCurrentVelocity()
    if (velocityTracker.yVelocity > requiredVelocity(...)) {
      // Fling'd!
      animateDismissal()
    }

    velocityTracker.recycle()
  }

  ACTION_MOVE -> {
    velocityTracker.addMovement(event)
  }
}

As a final polish, Dank adds a subtle animation for making the entry transition look pleasing. This is not included in the library, but can easily be done using a Picasso/Glide target (example).