Skip to content

Instantly share code, notes, and snippets.

@sigmabeta
Created April 17, 2016 04:33
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sigmabeta/70ce3c83a31907e30eb0b9d8128cafd9 to your computer and use it in GitHub Desktop.
Save sigmabeta/70ce3c83a31907e30eb0b9d8128cafd9 to your computer and use it in GitHub Desktop.
Custom Activity Transition for Picasso CenterCrop'd ImageViews
import android.animation.*
import android.graphics.Matrix
import android.graphics.PointF
import android.graphics.Rect
import android.transition.Transition
import android.transition.TransitionValues
import android.util.Property
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import java.lang.reflect.Method
import java.util.*
class CustomImageTransform(val enter: Boolean) : Transition() {
override fun captureStartValues(transitionValues: TransitionValues) {
captureValues(transitionValues)
}
override fun captureEndValues(transitionValues: TransitionValues) {
captureValues(transitionValues)
}
/**
* Places the bounds of the view before and after animation into
* the TransitionValues K/V map, which is passed to createAnimator().
*/
private fun captureValues(transitionValues: TransitionValues) {
val view = transitionValues.view
if (view !is ImageView || view.visibility != View.VISIBLE) {
return
}
val values = transitionValues.values
val left = view.getLeft()
val top = view.getTop()
val right = view.getRight()
val bottom = view.getBottom()
val bounds = Rect(left, top, right, bottom)
values.put(PROPERTY_BOUNDS, bounds)
}
/**
* Pulls out the bounds collected above in captureValues(), then feeds that information to the three
* animator creation methods, which hopefully all return an actual animator; but either way, the animators
* are combined into an AnimatorSet, and returned to the Transition framework.
*/
override fun createAnimator(sceneRoot: ViewGroup, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
startValues ?: return null
endValues ?: return null
val startBounds = startValues.values[PROPERTY_BOUNDS] as Rect
val endBounds = endValues.values[PROPERTY_BOUNDS] as Rect
if (startBounds == endBounds) {
return null
}
val imageView = endValues.view as ImageView
val drawable = imageView.drawable
val drawableWidth = drawable.intrinsicWidth
val drawableHeight = drawable.intrinsicHeight
val animators = ArrayList<Animator>(3)
createMatrixAnimator(imageView, startBounds, endBounds, drawableWidth, drawableHeight).let { animators.add(it) }
createPositionAnimator(imageView, startBounds, endBounds)?.let { animators.add(it) }
createClipAnimator(imageView, startBounds, endBounds)?.let { animators.add(it) }
val set = AnimatorSet()
set.playTogether(animators)
return set
}
/**
* The main secret sauce here. Since Picasso throws out all the data it doesn't display, it doesn't use
* a Matrix centerCrop your image. It doesn't set a Matrix at all, really. So ChangeImageTransform doesn't find
* one, and basically gives up. Here, we create Matrices using the information available to us, and create
* an animation out of it. Lemonade out of lemons, if you will.
*
* The Matrix is used to position the bitmap inside the ImageView's bounds, possibly scaling and skewing it as well.
* Without this animation, the image stays the same size throughout the transition, and sticks to the top left of
* its bounds.
*/
private fun createMatrixAnimator(imageView: ImageView, startBounds: Rect, endBounds: Rect, drawableWidth: Int, drawableHeight: Int): ObjectAnimator {
val startLeft = startBounds.left
val startTop = startBounds.top
val startRight = startBounds.right
val startBottom = startBounds.bottom
val endLeft = endBounds.left
val endTop = endBounds.top
val endRight = endBounds.right
val endBottom = endBounds.bottom
val startWidth = startRight - startLeft
val startHeight = startBottom - startTop
val endWidth = endRight - endLeft
val endHeight = endBottom - endTop
val smallWidth = Math.min(startWidth, endWidth)
val smallHeight = Math.min(startHeight, endHeight)
// Determine the direction in which the start & end images are being clipped.
val startClipDirection = if ((drawableWidth / drawableHeight) > (startWidth / startHeight)) true else false
val endClipDirection = if ((drawableWidth / drawableHeight) > (endWidth / endHeight)) true else false
// Create a pair of Identity Matrices.
val smallMatrix = Matrix()
val bigMatrix = Matrix()
val scaleFactor: Float
val smallTransX: Float
val smallTransY: Float
val startMatrix: Matrix
val endMatrix: Matrix
// The smaller image's matrix is the one that must be configured.
// If this is an "enter" transition, that is the starting Matrix.
if (enter) {
startMatrix = smallMatrix
endMatrix = bigMatrix
// If the smaller ImageView's aspect ratio is taller than the source Drawable.
if (startClipDirection) {
scaleFactor = startHeight.toFloat() / endHeight.toFloat()
smallTransX = ((scaleFactor * drawableWidth) - smallWidth) / -2.0f
smallTransY = 0.0f
} else {
scaleFactor = startWidth.toFloat() / endWidth.toFloat()
smallTransY = ((scaleFactor * drawableHeight) - smallHeight) / -2.0f
smallTransX = 0.0f
}
} else {
startMatrix = bigMatrix
endMatrix = smallMatrix
// If this is a "return" transition, the ending image is the smaller one,
// and so we check if its aspect ratio is taller than the source Drawable.
if (endClipDirection) {
scaleFactor = endHeight.toFloat() / startHeight.toFloat()
smallTransX = ((scaleFactor * drawableWidth) - smallWidth) / -2.0f
smallTransY = 0.0f
} else {
scaleFactor = endWidth.toFloat() / startWidth.toFloat()
smallTransY = ((scaleFactor * drawableHeight) - smallHeight) / -2.0f
smallTransX = 0.0f
}
}
// Configure the "small" matrix.
val smallMatrixValues = FloatArray(9)
smallMatrix.getValues(smallMatrixValues)
smallMatrixValues[Matrix.MTRANS_X] = smallTransX
smallMatrixValues[Matrix.MTRANS_Y] = smallTransY
smallMatrixValues[Matrix.MSCALE_X] = scaleFactor
smallMatrixValues[Matrix.MSCALE_Y] = scaleFactor
smallMatrix.setValues(smallMatrixValues)
return ObjectAnimator.ofObject(imageView, ANIMATED_TRANSFORM_PROPERTY, CustomMatrixEvaluator(), startMatrix, endMatrix)
}
/**
* Animates the view's position. Without this, the ImageView will resize and clip properly,
* but the top left corner of the view will not move to its proper location, or at all.
*/
private fun createPositionAnimator(view: View, startBounds: Rect, endBounds: Rect): ObjectAnimator? {
val startLeft = startBounds.left
val endLeft = endBounds.left
val startTop = startBounds.top
val endTop = endBounds.top
if (startLeft != endLeft || startTop != endTop) {
val topLeftPath = pathMotion.getPath(startLeft.toFloat(), startTop.toFloat(), endLeft.toFloat(), endTop.toFloat())
return ObjectAnimator.ofObject<View, PointF>(view, POSITION_PROPERTY, null, topLeftPath)
}
return null
}
/**
* Animates the view's clipping box. Without this animation, the ImageView will be occluded
* by other views on screen.
*/
private fun createClipAnimator(view: View, startBounds: Rect, endBounds: Rect): ValueAnimator? {
val startLeft = startBounds.left
val startTop = startBounds.top
val startRight = startBounds.right
val startBottom = startBounds.bottom
val endLeft = endBounds.left
val endTop = endBounds.top
val endRight = endBounds.right
val endBottom = endBounds.bottom
val startWidth = startRight - startLeft
val startHeight = startBottom - startTop
val endWidth = endRight - endLeft
val endHeight = endBottom - endTop
val maxWidth = Math.max(startWidth, endWidth)
val maxHeight = Math.max(startHeight, endHeight)
setLeftTopRightBottom(view, startLeft, startTop, startLeft + maxWidth, startTop + maxHeight)
val startClip = Rect(0, 0, startWidth, startHeight)
val endClip = Rect(0, 0, endWidth, endHeight)
if (startClip != endClip) {
view.clipBounds = startClip
val clipAnimator = ValueAnimator.ofObject(EVALUATOR_RECT, startClip, endClip)
clipAnimator.addUpdateListener { animation ->
val clipBounds = animation.animatedValue as Rect
view.clipBounds = clipBounds
}
clipAnimator.addListener(object : AnimatorListenerAdapter() {
private var canceled = false
override fun onAnimationCancel(animation: Animator) {
canceled = true
}
override fun onAnimationEnd(animation: Animator) {
if (!canceled) {
view.clipBounds = endClip
setLeftTopRightBottom(view, endLeft, endTop, endRight, endBottom)
}
}
})
return clipAnimator
}
return null
}
/**
* Kotlin-ese for "static stuff goes here"
*/
companion object {
val PROPERTY_BOUNDS = "${BuildConfig.APPLICATION_ID}.transition.property.bounds"
val EVALUATOR_RECT = RectEvaluator()
var METHOD_ANIMATE_TRANSFORM: Method? = null
private val ANIMATED_TRANSFORM_PROPERTY = object : Property<ImageView, Matrix>(Matrix::class.java, "animatedTransform") {
override fun set(image: ImageView, value: Matrix) {
animateTransform(image, value)
}
override fun get(image: ImageView): Matrix? {
return null
}
}
private val POSITION_PROPERTY = object : Property<View, PointF>(PointF::class.java, "position") {
override fun set(view: View, topLeft: PointF) {
val left = Math.round(topLeft.x)
val top = Math.round(topLeft.y)
val right = left + view.width
val bottom = top + view.height
setLeftTopRightBottom(view, left, top, right, bottom)
}
override fun get(view: View): PointF? {
return null
}
}
private fun animateTransform(image: ImageView, value: Matrix) {
/**
* Doesn't really seem to be a way to avoid calling this method using reflection.
*/
if (METHOD_ANIMATE_TRANSFORM == null) {
val clazz = Class.forName("android.widget.ImageView")
METHOD_ANIMATE_TRANSFORM = clazz.getMethod("animateTransform", Matrix::class.java)
}
METHOD_ANIMATE_TRANSFORM!!.invoke(image, value)
}
/**
* Calling the framework's implementation, as ChangeBounds() does, breaks the Matrix animation
* because the Matrix we write to the ImageView in that animation gets overwritten. So we just
* set the bounds manually here.
*/
private fun setLeftTopRightBottom(view: View, left: Int, top: Int, right: Int, bottom: Int) {
view.left = left
view.right = right
view.top = top
view.bottom = bottom
view.invalidate()
}
}
}
import android.animation.TypeEvaluator
import android.graphics.Matrix
class CustomMatrixEvaluator : TypeEvaluator<Matrix> {
val startValueArray = FloatArray(9)
val endValueArray = FloatArray(9)
var tempStartValues = FloatArray(9)
var tempEndValues = FloatArray(9)
var tempMatrix = Matrix()
override fun evaluate(fraction: Float, startValue: Matrix, endValue: Matrix): Matrix {
startValue.getValues(startValueArray)
endValue.getValues(endValueArray)
startValue.getValues(tempStartValues)
endValue.getValues(tempEndValues)
for (i in 0..8) {
val diff = tempEndValues[i] - tempStartValues[i]
val result = tempStartValues[i] + fraction * diff
tempEndValues[i] = result
}
tempMatrix.setValues(tempEndValues)
return tempMatrix
}
}
class DetailActivity() : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
val enter = CustomImageTransform(true)
val returnTransition = CustomImageTransform(false)
window.sharedElementEnterTransition = enter
window.sharedElementReturnTransition = returnTransition
}
// ...
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment