Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cancel active fling animation when appropriate #85

Merged
merged 7 commits into from
Feb 12, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ will make more sense than the other - e. g., in a PDF viewer, you might want to
|`zoomOut()`|Applies a small, animated zoom-out.|`-`|
|`setZoomEnabled(boolean)`|If true, the content will be allowed to zoom in and out by user input.|`true`|


The `moveTo(float, float, float, boolean)` API will let you animate both zoom and [pan](#pan) at the same time.

### Pan
Expand All @@ -267,6 +268,7 @@ In any case the current scale is not considered, so your system won't change if
|`setAllowFlingInOverscroll(boolean)`|If true, fling gestures will be allowed even when detected while overscrolled. This might cause artifacts so it is disabled by default.|`false`|
|`panTo(float, float, boolean)`|Pans to the given values, animating if needed.|`-`|
|`panBy(float, float, boolean)`|Applies the given deltas to the current pan, animating if needed.|`-`|
|`cancelAnimations()`|Cancels all currently active animations triggered by either API calls with `animate = true` or touch input flings.|`-`|

The `moveTo(float, float, float, boolean)` API will let you animate both [zoom](#zoom) and pan at the same time.

Expand Down
8 changes: 8 additions & 0 deletions library/src/main/java/com/otaliastudios/zoom/ZoomApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,14 @@ interface ZoomApi {
*/
fun setAnimationDuration(duration: Long)

/**
* Cancels all currently active animations triggered by either API calls with `animate = true`
* or touch input flings. If no animation is currently active this is a no-op.
*
* @return true if anything was cancelled, false otherwise
*/
fun cancelAnimations(): Boolean

companion object {

/**
Expand Down
127 changes: 70 additions & 57 deletions library/src/main/java/com/otaliastudios/zoom/ZoomEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener
private var mInitialized = false
private var mContentScaledRect = RectF()
private var mContentRect = RectF()
private var mClearAnimation = false
private var mAnimationDuration = DEFAULT_ANIMATION_DURATION

// Gestures
Expand Down Expand Up @@ -677,27 +676,31 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener

// Returns true if we should go to that mode.
@SuppressLint("SwitchIntDef")
private fun setState(@State state: Int): Boolean {
LOG.v("trySetState:", state.toStateName())
private fun setState(@State newState: Int): Boolean {
LOG.v("trySetState:", newState.toStateName())
if (!mInitialized) return false
if (state == mState) return true
val oldMode = mState

when (state) {
SCROLLING -> if (oldMode == PINCHING || oldMode == ANIMATING) return false
FLINGING -> if (oldMode == ANIMATING) return false
PINCHING -> if (oldMode == ANIMATING) return false
// we need to do some cleanup in case of ANIMATING so we can't return just yet
if (newState == mState && newState != ANIMATING) return true
val oldState = mState

when (newState) {
SCROLLING -> if (oldState == PINCHING || oldState == ANIMATING) return false
FLINGING -> if (oldState == ANIMATING) return false
PINCHING -> if (oldState == ANIMATING) return false
NONE -> dispatchOnIdle()
}

// Now that it succeeded, do some cleanup.
when (oldMode) {
when (oldState) {
ANIMATING -> {
mActiveAnimators.forEach { it.cancel() }
mActiveAnimators.clear()
}
FLINGING -> mFlingScroller.forceFinished(true)
ANIMATING -> mClearAnimation = true
}

LOG.i("setState:", state.toStateName())
mState = state
LOG.i("setState:", newState.toStateName())
mState = newState
return true
}

Expand Down Expand Up @@ -1131,6 +1134,7 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener
if (animate) {
animateZoomAndAbsolutePan(zoom, x, y, allowOverScroll = false)
} else {
cancelAnimations()
applyZoomAndAbsolutePan(zoom, x, y, allowOverScroll = false)
}
}
Expand Down Expand Up @@ -1166,6 +1170,7 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener
if (animate) {
animateZoomAndAbsolutePan(zoom, panX + dx, panY + dy, allowOverScroll = false)
} else {
cancelAnimations()
applyZoomAndAbsolutePan(zoom, panX + dx, panY + dy, allowOverScroll = false)
}
}
Expand All @@ -1182,6 +1187,7 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener
if (animate) {
animateZoom(zoom, allowOverPinch = false)
} else {
cancelAnimations()
applyZoom(zoom, allowOverPinch = false)
}
}
Expand All @@ -1202,17 +1208,13 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener
* Applies a small, animated zoom-in.
* Shorthand for [zoomBy] with factor 1.3.
*/
override fun zoomIn() {
zoomBy(1.3f, animate = true)
}
override fun zoomIn() = zoomBy(1.3f, animate = true)

/**
* Applies a small, animated zoom-out.
* Shorthand for [zoomBy] with factor 0.7.
*/
override fun zoomOut() {
zoomBy(0.7f, animate = true)
}
override fun zoomOut() = zoomBy(0.7f, animate = true)

/**
* Animates the actual matrix zoom to the given value.
Expand Down Expand Up @@ -1277,24 +1279,46 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener

//region Apply values

private val mActiveAnimators = mutableSetOf<ValueAnimator>()
private val mCancelAnimationListener = object : AnimatorListenerAdapter() {

override fun onAnimationEnd(animation: Animator?) {
setState(NONE)
override fun onAnimationEnd(animator: Animator) {
cleanup(animator)
}

override fun onAnimationCancel(animation: Animator?) {
setState(NONE)
override fun onAnimationCancel(animator: Animator) {
cleanup(animator)
}

private fun cleanup(animator: Animator) {
animator.removeListener(this)
mActiveAnimators.remove(animator)
if (mActiveAnimators.isEmpty()) setState(NONE)
}
}

/**
* Prepares a [ValueAnimator] for the first run
*
* @return itself (for chaining)
*/
private fun ValueAnimator.prepare() {
private fun ValueAnimator.prepare(): ValueAnimator {
this.duration = mAnimationDuration
this.addListener(mCancelAnimationListener)
this.interpolator = ANIMATION_INTERPOLATOR
return this
}

/**
* Starts a [ValueAnimator] with the given update function
*
* @return itself (for chaining)
*/
private fun ValueAnimator.start(onUpdate: (ValueAnimator) -> Unit): ValueAnimator {
this.addUpdateListener(onUpdate)
this.start()
mActiveAnimators.add(this)
return this
}

/**
Expand All @@ -1306,21 +1330,12 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener
*/
private fun animateZoom(@Zoom zoom: Float, allowOverPinch: Boolean) {
if (setState(ANIMATING)) {
mClearAnimation = false
@Zoom val startZoom = this.zoom
@Zoom val endZoom = checkZoomBounds(zoom, allowOverPinch)
val zoomAnimator = ValueAnimator.ofFloat(startZoom, endZoom)
zoomAnimator.prepare()
zoomAnimator.addUpdateListener {
ValueAnimator.ofFloat(startZoom, endZoom).prepare().start {
LOG.v("animateZoom:", "animationStep:", it.animatedFraction)
if (mClearAnimation) {
it.cancel()
return@addUpdateListener
}
applyZoom(it.animatedValue as Float, allowOverPinch)
}

zoomAnimator.start()
}
}

Expand All @@ -1336,23 +1351,22 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener
* @param zoomTargetX the x-axis zoom target
* @param zoomTargetY the y-axis zoom target
*/
@SuppressLint("ObjectAnimatorBinding")
private fun animateZoomAndAbsolutePan(@Zoom zoom: Float,
@AbsolutePan x: Float, @AbsolutePan y: Float,
allowOverScroll: Boolean,
allowOverPinch: Boolean = false,
zoomTargetX: Float? = null,
zoomTargetY: Float? = null) {
if (setState(ANIMATING)) {
mClearAnimation = false
@Zoom val startZoom = this.zoom
@Zoom val endZoom = checkZoomBounds(zoom, allowOverScroll)
val startPan = pan
val targetPan = AbsolutePoint(x, y)
LOG.i("animateZoomAndAbsolutePan:", "starting.", "startX:", startPan.x, "endX:", x, "startY:", startPan.y, "endY:", y)
LOG.i("animateZoomAndAbsolutePan:", "starting.", "startZoom:", startZoom, "endZoom:", endZoom)

@SuppressLint("ObjectAnimatorBinding")
val animator = ObjectAnimator.ofPropertyValuesHolder(mContainer,
ObjectAnimator.ofPropertyValuesHolder(
PropertyValuesHolder.ofObject(
"pan",
TypeEvaluator { fraction: Float, startValue: AbsolutePoint, endValue: AbsolutePoint ->
Expand All @@ -1362,20 +1376,13 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener
PropertyValuesHolder.ofFloat(
"zoom",
startZoom, endZoom)
)
animator.prepare()
animator.addUpdateListener {
if (mClearAnimation) {
it.cancel()
return@addUpdateListener
}
).prepare().start {
val newZoom = it.getAnimatedValue("zoom") as Float
val currentPan = it.getAnimatedValue("pan") as AbsolutePoint
applyZoomAndAbsolutePan(newZoom, currentPan.x, currentPan.y,
allowOverScroll, allowOverPinch,
zoomTargetX, zoomTargetY)
}
animator.start()
}
}

Expand All @@ -1390,25 +1397,16 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener
private fun animateScaledPan(@ScaledPan deltaX: Float, @ScaledPan deltaY: Float,
allowOverScroll: Boolean) {
if (setState(ANIMATING)) {
mClearAnimation = false
val startPan = scaledPan
val endPan = startPan + ScaledPoint(deltaX, deltaY)

val panAnimator = ValueAnimator.ofObject(TypeEvaluator { fraction, startValue: ScaledPoint, endValue: ScaledPoint ->
ValueAnimator.ofObject(TypeEvaluator { fraction, startValue: ScaledPoint, endValue: ScaledPoint ->
startValue + (endValue - startValue) * fraction - scaledPan
}, startPan, endPan)
panAnimator.prepare()
panAnimator.addUpdateListener {
}, startPan, endPan).prepare().start {
LOG.v("animateScaledPan:", "animationStep:", it.animatedFraction)
if (mClearAnimation) {
it.cancel()
return@addUpdateListener
}
val currentPan = it.animatedValue as ScaledPoint
applyScaledPan(currentPan.x, currentPan.y, allowOverScroll)
}

panAnimator.start()
}
}

Expand Down Expand Up @@ -1596,6 +1594,21 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener
return true
}

/**
* Cancels all currently active animations triggered by either API calls with `animate = true`
* or touch input flings. If no animation is currently active this is a no-op.
*
* @return true if anything was cancelled, false otherwise
*/
override fun cancelAnimations(): Boolean {
if (mState == FLINGING || mState == ANIMATING) {
setState(NONE)
return true
}

return false
}

//endregion

//region scrollbars helpers
Expand Down