diff --git a/README.md b/README.md index 3305619..5f0eab0 100644 --- a/README.md +++ b/README.md @@ -40,17 +40,22 @@ A container for view hierarchies that can be panned or zoomed. @@ -89,24 +94,29 @@ An `ImageView` implementation to control pan and zoom over its Drawable or Bitma + app:maxZoom="2.5" + app:maxZoomType="zoom" + app:animationDuration="280"/> ``` There is nothing surprising going on. Just call `setImageDrawable()` and you are done. Presumably ZoomImageView **won't** work if: -- the drawable has no intrinsic dimensions +- the drawable has no intrinsic dimensions (like a ColorDrawable) - the view has wrap_content as a dimension - you change the scaleType (read [later](#zoom) to know more) @@ -142,11 +152,17 @@ There is no strict limit over what you can do with a `Matrix`, ### Zoom -#### Transformations +#### Transformation + +The transformation defines the engine **resting position**. It is a keyframe that is reached at +certain points, like at start-up or when explicitly requested through `setContentSize` or `setContainerSize`. + +The keyframe is defined by two elements: -When the engine becomes aware of the content size, it will apply a base transformation to the content -that can be controlled through `setTransformation(int, int)` or `app:transformation` and `app:transformationGravity`. -By default it is applied only once, and defines the starting viewport over our content. +- a `transformation` value (modifies zoom in a certain way) +- a `transformationGravity` value (modifies pan in a certain way) + +which can be controlled through `setTransformation(int, int)` or `app:transformation` and `app:transformationGravity`. |Transformation|Description| |--------------|-----------| @@ -154,34 +170,42 @@ By default it is applied only once, and defines the starting viewport over our c |`centerCrop`|The content is scaled down or up so that its smaller side fits exactly inside the view bounds. The larger side will be cropped.| |`none`|No transformation is applied.| -The engine applies the given transformation, and any minZoom and maxZoom constraints. - -If, after this process, the content is bigger than the container, the engine will also apply a -translation according to the given transformation gravity. +After transformation is applied, the transformation gravity will reposition the content with +the specified value. Supported values are most of the `android.view.Gravity` flags like `Gravity.TOP`, plus `TRANSFORMATION_GRAVITY_AUTO`. |Transformation Gravity|Description| |----------------------|-----------| -|`top`|If the content is taller than the view, translate it so that we see the top part.| -|`bottom`|If the content is taller than the view, translate it so that we see the bottom part.| -|`left`|If the content is wider than the view, translate it so that we see the left part.| -|`right`|If the content is wider than the view, translate it so that we see the right part.| +|`top`, ...|The content is panned so that its *top* side matches teh container *top* side. Same for other values.| +|`auto` (default)|The transformation gravity is taken from the engine [alignment](#alignment), defaults to `center` on both axes.| -If, after this process, the content is smaller than the container, note that the current -[Smaller Policy](#smaller-policy) applies. +**Note: after transformation and gravity are applied, the engine will apply - as always - all the active constraints, +including minZoom, maxZoom, alignment. This means that the final position might be slightly (or completely) different.** -Note: you can always trigger a new transformation to be applied by using the `setContentSize` or `setContainerSize` APIs. +For example, when `maxZoom == 1`, the content is forced to not be any larger than the container. This means that +a `centerCrop` transformation will not have the desired effect: it will act just like a `centerInside`. -#### Smaller policy +#### Alignment -You can control how the content will be positioned when it is smaller than the container through -the `setSmallerPolicy(int)` method or by using the `smallerPolicy` XML attribute of `ZoomLayout` and `ZoomImageView`. -By default, content is always centered when it is smaller than its container. +You can force the content position with respect to the container using the `setAlignment(int)` method +or the `alignment` XML flag of `ZoomLayout` and `ZoomImageView`. +The default value is `Alignment.CENTER` which will center the content on both directions. -|Policy|Description| -|------|-----------| -|`center`|The content will be centered within the container.| -|`fromTransformation`|The content will respect the gravity parameter of the transformation (which defaults to `Gravity.CENTER` as well).| -|`none`|The content is free to be moved around inside the container bounds.| +**Note: alignment does not make sense when content is larger than the container, because forcing an +alignment (e.g. left) would mean making part of the content unreachable (e.g. the right part).** + +|Alignment|Description| +|---------|-----------| +|`top`, `bottom`, `left`, `right`|Force align the content to the same side of the container.| +|`center_horizontal`, `center_vertical`|Force the content to be centered inside the container on that axis.| +|`none_horizontal`, `none_vertical`|No alignment set: content is free to be moved on that axis.| + +You can use the `or` operation to mix the vertical and horizontal flags: + +```kotlin +engine.setAlignment(Alignment.TOP or Alignment.LEFT) +engine.setAlignment(Alignment.TOP) // Equals to Aligment.TOP or Alignment.NONE_HORIZONTAL +engine.setAlignment(Alignment.NONE) // Remove any forced alignment +``` #### Zoom Types diff --git a/build.gradle b/build.gradle index 5e7b2a4..31f74e8 100644 --- a/build.gradle +++ b/build.gradle @@ -10,9 +10,9 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:3.2.1' classpath 'com.github.dcendents:android-maven-gradle-plugin:2.1' - classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.0' + classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.4' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "org.jetbrains.dokka:dokka-android-gradle-plugin:+" + classpath "org.jetbrains.dokka:dokka-android-gradle-plugin:0.9.17" } } diff --git a/library/build.gradle b/library/build.gradle index 47f5523..430aaa3 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -32,7 +32,7 @@ android { dependencies { api "androidx.annotation:annotation:1.0.1" - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + api "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" testImplementation "org.junit.jupiter:junit-jupiter-api:5.3.1" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.3.1" diff --git a/library/src/main/java/com/otaliastudios/zoom/Alignment.kt b/library/src/main/java/com/otaliastudios/zoom/Alignment.kt new file mode 100644 index 0000000..520b594 --- /dev/null +++ b/library/src/main/java/com/otaliastudios/zoom/Alignment.kt @@ -0,0 +1,128 @@ +package com.otaliastudios.zoom + +import android.annotation.SuppressLint +import android.view.Gravity + +/** + * Holds constants for [ZoomApi.setAlignment]. + */ +object Alignment { + + // Will use one hexadecimal value for each axis, so 16 possible values. + internal const val MASK = 0xF0 // 1111 0000 + + // A special value meaning that the flag for some axis was not set. + internal const val NO_VALUE = 0x0 + + // Vertical + + /** + * Aligns top side of the content to the top side of the container. + */ + const val TOP = 0x01 // 0000 0001 + + /** + * Aligns the bottom side of the content to the bottom side of the container. + */ + const val BOTTOM = 0x02 // 0000 0010 + + /** + * Centers the content vertically inside the container. + */ + const val CENTER_VERTICAL = 0x03 // 0000 0011 + + /** + * No forced alignment on the vertical axis. + */ + const val NONE_VERTICAL = 0x04 // 0000 0100 + + // Horizontal + + /** + * Aligns left side of the content to the left side of the container. + */ + const val LEFT = 0x10 // 0001 0000 + + /** + * Aligns right side of the content to the right side of the container. + */ + const val RIGHT = 0x20 // 0010 0000 + + /** + * Centers the content horizontally inside the container. + */ + const val CENTER_HORIZONTAL = 0x30 // 0011 0000 + + /** + * No forced alignment on the horizontal axis. + */ + const val NONE_HORIZONTAL = 0x40 // 0100 0000 + + // TODO support START and END + + /** + * Shorthand for [CENTER_HORIZONTAL] and [CENTER_VERTICAL] together. + */ + const val CENTER = CENTER_VERTICAL or CENTER_HORIZONTAL + + /** + * Shorthand for [NONE_HORIZONTAL] and [NONE_VERTICAL] together. + */ + const val NONE = NONE_VERTICAL or NONE_HORIZONTAL + + /** + * Returns the horizontal alignment for this alignment, + * or [NO_VALUE] if no value was set. + */ + internal fun getHorizontal(alignment: Int): Int { + return alignment and MASK + } + + /** + * Returns the vertical alignment for this alignment, + * or [NO_VALUE] if no value was set. + */ + internal fun getVertical(alignment: Int): Int { + return alignment and MASK.inv() + } + + /** + * Returns whether this alignment is of 'none' type. + * In case [alignment] includes both axes, both are required to be 'none' or [NO_VALUE]. + */ + internal fun isNone(alignment: Int): Boolean { + return alignment == Alignment.NONE + || alignment == Alignment.NO_VALUE + || alignment == Alignment.NONE_HORIZONTAL + || alignment == Alignment.NONE_VERTICAL + } + + /** + * Transforms this alignment to a horizontal gravity value. + */ + @SuppressLint("RtlHardcoded") + internal fun toHorizontalGravity(alignment: Int, valueIfNone: Int): Int { + val horizontalAlignment = getHorizontal(alignment) + return when (horizontalAlignment) { + Alignment.LEFT -> Gravity.LEFT + Alignment.RIGHT -> Gravity.RIGHT + Alignment.CENTER_HORIZONTAL -> Gravity.CENTER_HORIZONTAL + Alignment.NONE_HORIZONTAL -> valueIfNone + else -> valueIfNone + } + } + + /** + * Transforms this alignment to a vertical gravity value. + */ + internal fun toVerticalGravity(alignment: Int, valueIfNone: Int): Int { + val verticalAlignment = getHorizontal(alignment) + return when (verticalAlignment) { + Alignment.TOP -> Gravity.TOP + Alignment.BOTTOM -> Gravity.BOTTOM + Alignment.CENTER_VERTICAL -> Gravity.CENTER_VERTICAL + Alignment.NONE_VERTICAL -> valueIfNone + else -> valueIfNone + } + } +} \ No newline at end of file diff --git a/library/src/main/java/com/otaliastudios/zoom/ZoomApi.kt b/library/src/main/java/com/otaliastudios/zoom/ZoomApi.kt index 77f8c90..39c011e 100644 --- a/library/src/main/java/com/otaliastudios/zoom/ZoomApi.kt +++ b/library/src/main/java/com/otaliastudios/zoom/ZoomApi.kt @@ -114,18 +114,30 @@ interface ZoomApi { annotation class ZoomType /** - * Defines the available transvormation types + * Defines the available transformation types */ @Retention(AnnotationRetention.SOURCE) @IntDef(TRANSFORMATION_CENTER_INSIDE, TRANSFORMATION_CENTER_CROP, TRANSFORMATION_NONE) annotation class Transformation /** - * Defines the available smaller policies + * Defines the available alignments */ @Retention(AnnotationRetention.SOURCE) - @IntDef(SMALLER_POLICY_CENTER, SMALLER_POLICY_FROM_TRANSFORMATION, SMALLER_POLICY_NONE) - annotation class SmallerPolicy + @IntDef( + com.otaliastudios.zoom.Alignment.BOTTOM, + com.otaliastudios.zoom.Alignment.CENTER_VERTICAL, + com.otaliastudios.zoom.Alignment.NONE_VERTICAL, + com.otaliastudios.zoom.Alignment.TOP, + com.otaliastudios.zoom.Alignment.LEFT, + com.otaliastudios.zoom.Alignment.CENTER_HORIZONTAL, + com.otaliastudios.zoom.Alignment.NONE_HORIZONTAL, + com.otaliastudios.zoom.Alignment.RIGHT, + com.otaliastudios.zoom.Alignment.CENTER, + com.otaliastudios.zoom.Alignment.NONE, + flag = true + ) + annotation class Alignment /** * Controls whether the content should be over-scrollable horizontally. @@ -189,6 +201,16 @@ interface ZoomApi { */ fun setAllowFlingInOverscroll(allow: Boolean) + /** + * Sets the base transformation to be applied to the content. + * See [setTransformation]. + * + * @param transformation the transformation type + */ + fun setTransformation(@Transformation transformation: Int) { + setTransformation(transformation, TRANSFORMATION_GRAVITY_AUTO) + } + /** * Sets the base transformation to be applied to the content. * Defaults to [TRANSFORMATION_CENTER_INSIDE] with [android.view.Gravity.CENTER], @@ -200,13 +222,16 @@ interface ZoomApi { fun setTransformation(@Transformation transformation: Int, gravity: Int) /** - * Sets the policy to use when content is smaller than the container. - * Defaults to [SMALLER_POLICY_CENTER] - * which means that the content will be centered. + * Sets the content alignment. Can be any of the constants defined in [com.otaliastudios.zoom.Alignment]. + * The content will be aligned and forced to the specified side of the container. + * Defaults to [ALIGNMENT_DEFAULT]. * - * @param policy the policy + * Keep in mind that this is disabled when the content is larger than the container, + * because a forced alignment in this case would result in part of the content being unreachable. + * + * @param alignment the new alignment */ - fun setSmallerPolicy(@SmallerPolicy policy: Int) + fun setAlignment(@Alignment alignment: Int) /** * A low level API that can animate both zoom and pan at the same time. @@ -354,23 +379,35 @@ interface ZoomApi { const val TRANSFORMATION_NONE = 2 /** - * Constant for [ZoomApi.setSmallerPolicy]. - * When smaller than its container, the content will be centered. + * Constant for [ZoomApi.setTransformation] gravity. + * This means that the gravity will be inferred from the alignment or + * fallback to a reasonable default. + */ + const val TRANSFORMATION_GRAVITY_AUTO = 0 + + /** + * The default [setMinZoom] applied by the engine if none is specified. + */ + const val MIN_ZOOM_DEFAULT = 0.8F + + /** + * The default [setMinZoom] type applied by the engine if none is specified. + */ + const val MIN_ZOOM_DEFAULT_TYPE = TYPE_ZOOM + + /** + * The default [setMaxZoom] applied by the engine if none is specified. */ - const val SMALLER_POLICY_CENTER = 0 + const val MAX_ZOOM_DEFAULT = 2.5F /** - * Constant for [ZoomApi.setSmallerPolicy]. - * When smaller than its container, the content will be bound to the container - * according to the transformation gravity. + * The default [setMaxZoom] type applied by the engine if none is specified. */ - const val SMALLER_POLICY_FROM_TRANSFORMATION = 1 + const val MAX_ZOOM_DEFAULT_TYPE = TYPE_ZOOM /** - * Constant for [ZoomApi.setSmallerPolicy]. - * When smaller than its container, the content is free to be moved around. - * It will just stay inside the container bounds. + * The default value for [setAlignment]. */ - const val SMALLER_POLICY_NONE = 2 + const val ALIGNMENT_DEFAULT = com.otaliastudios.zoom.Alignment.CENTER } } \ No newline at end of file diff --git a/library/src/main/java/com/otaliastudios/zoom/ZoomEngine.kt b/library/src/main/java/com/otaliastudios/zoom/ZoomEngine.kt index 86a01b0..f682be2 100644 --- a/library/src/main/java/com/otaliastudios/zoom/ZoomEngine.kt +++ b/library/src/main/java/com/otaliastudios/zoom/ZoomEngine.kt @@ -58,10 +58,10 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener } // Options - private var mMinZoom = 0.8f - private var mMinZoomMode = ZoomApi.TYPE_ZOOM - private var mMaxZoom = 2.5f - private var mMaxZoomMode = ZoomApi.TYPE_ZOOM + private var mMinZoom = ZoomApi.MIN_ZOOM_DEFAULT + private var mMinZoomMode = ZoomApi.MIN_ZOOM_DEFAULT_TYPE + private var mMaxZoom = ZoomApi.MAX_ZOOM_DEFAULT + private var mMaxZoomMode = ZoomApi.MAX_ZOOM_DEFAULT_TYPE private var mOverScrollHorizontal = true private var mOverScrollVertical = true private var mHorizontalPanEnabled = true @@ -71,19 +71,19 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener private var mFlingEnabled = true private var mAllowFlingInOverscroll = false private var mTransformation = ZoomApi.TRANSFORMATION_CENTER_INSIDE - private var mTransformationGravity = Gravity.CENTER - private var mSmallerPolicy = ZoomApi.SMALLER_POLICY_CENTER + private var mTransformationGravity = ZoomApi.TRANSFORMATION_GRAVITY_AUTO + private var mAlignment = ZoomApi.ALIGNMENT_DEFAULT // Internal private val mListeners = mutableListOf() private var mMatrix = Matrix() - private var mBaseZoom = 0F // mZoom * mBaseZoom matches the matrix scale. + private var mTransformationZoom = 0F // mZoom * mTransformationZoom matches the matrix scale. @State private var mState = NONE private lateinit var mContainer: View private var mContainerWidth = 0F private var mContainerHeight = 0F private var mInitialized = false - private var mTransformedRect = RectF() + private var mContentScaledRect = RectF() private var mContentRect = RectF() private var mClearAnimation = false private var mAnimationDuration = DEFAULT_ANIMATION_DURATION @@ -97,11 +97,11 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener @ScaledPan private val mContentScaledWidth: Float - get() = mTransformedRect.width() + get() = mContentScaledRect.width() @ScaledPan private val mContentScaledHeight: Float - get() = mTransformedRect.height() + get() = mContentScaledRect.height() @AbsolutePan private val mContentWidth: Float @@ -170,7 +170,7 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener */ @RealZoom override val realZoom: Float - get() = zoom * mBaseZoom + get() = zoom * mTransformationZoom /** * The current pan as an [AbsolutePoint]. @@ -224,7 +224,7 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener */ @ScaledPan private val scaledPanX: Float - get() = mTransformedRect.left + get() = mContentScaledRect.left /** * The current vertical scaled pan, which is the pan position of the content @@ -232,7 +232,7 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener */ @ScaledPan private val scaledPanY: Float - get() = mTransformedRect.top + get() = mContentScaledRect.top @Retention(AnnotationRetention.SOURCE) @IntDef(NONE, SCROLLING, PINCHING, ANIMATING, FLINGING) @@ -428,8 +428,18 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener mTransformationGravity = gravity } - override fun setSmallerPolicy(@SmallerPolicy policy: Int) { - mSmallerPolicy = policy + /** + * Sets the content alignment. Can be any of the constants defined in [Alignment]. + * The content will be aligned and forced to the specified side of the container. + * Defaults to [ZoomApi.ALIGNMENT_DEFAULT]. + * + * Keep in mind that this is disabled when the content is larger than the container, + * because a forced alignment in this case would result in part of the content being unreachable. + * + * @param alignment the new alignment + */ + override fun setAlignment(@ZoomApi.Alignment alignment: Int) { + mAlignment = alignment } //endregion @@ -503,7 +513,7 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener private fun onSizeChanged(applyTransformation: Boolean) { // We will sync them later using matrix.mapRect. - mTransformedRect.set(mContentRect) + mContentScaledRect.set(mContentRect) if (mContentWidth <= 0 || mContentHeight <= 0 @@ -523,17 +533,17 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener LOG.w("onSizeChanged: will apply?", apply, "transformation?", mTransformation) if (apply) { // First time. Apply base zoom, dispatch first event and return. - mBaseZoom = computeBaseZoom() - mMatrix.setScale(mBaseZoom, mBaseZoom) - mMatrix.mapRect(mTransformedRect, mContentRect) + mTransformationZoom = computeTransformationZoom() + mMatrix.setScale(mTransformationZoom, mTransformationZoom) + mMatrix.mapRect(mContentScaledRect, mContentRect) zoom = 1f - LOG.i("onSizeChanged: newBaseZoom:", mBaseZoom, "newZoom:", zoom) + LOG.i("onSizeChanged: newTransformationZoom:", mTransformationZoom, "newZoom:", zoom) @Zoom val newZoom = checkZoomBounds(zoom, false) LOG.i("onSizeChanged: scaleBounds:", "we need a zoom correction of", newZoom - zoom) if (newZoom != zoom) applyZoom(newZoom, allowOverPinch = false) // pan based on transformation gravity. - @ScaledPan val newPan = computeBasePan() + @ScaledPan val newPan = computeTransformationPan() @ScaledPan val deltaX = newPan[0] - scaledPanX @ScaledPan val deltaY = newPan[1] - scaledPanY if (deltaX != 0f || deltaY != 0f) applyScaledPan(deltaX, deltaY, false) @@ -545,19 +555,19 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener } } else { // We were initialized, but some size changed. Since applyTransformation is false, - // we must do extra work: recompute the baseZoom (since size changed, it makes no sense) + // we must do extra work: recompute the transformationZoom (since size changed, it makes no sense) // but also compute a new zoom such that the real zoom is kept unchanged. // So, this method triggers no Matrix updates. LOG.i("onSizeChanged: Trying to keep real zoom to", realZoom) - LOG.i("onSizeChanged: oldBaseZoom:", mBaseZoom, "oldZoom:$zoom") + LOG.i("onSizeChanged: oldTransformationZoom:", mTransformationZoom, "oldZoom:$zoom") @RealZoom val realZoom = realZoom - mBaseZoom = computeBaseZoom() - zoom = realZoom / mBaseZoom - LOG.i("onSizeChanged: newBaseZoom:", mBaseZoom, "newZoom:", zoom) + mTransformationZoom = computeTransformationZoom() + zoom = realZoom / mTransformationZoom + LOG.i("onSizeChanged: newTransformationZoom:", mTransformationZoom, "newZoom:", zoom) // Now sync the content rect with the current matrix since we are trying to keep it. // This is to have consistent values for other calls here. - mMatrix.mapRect(mTransformedRect, mContentRect) + mMatrix.mapRect(mContentScaledRect, mContentRect) // If the new zoom value is invalid, though, we must bring it to the valid place. // This is a possible matrix update. @@ -582,8 +592,8 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener mContainerHeight = 0f mContainerWidth = 0f zoom = 1f - mBaseZoom = 0f - mTransformedRect = RectF() + mTransformationZoom = 0f + mContentScaledRect = RectF() mContentRect = RectF() mMatrix = Matrix() mInitialized = false @@ -592,18 +602,18 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener /** * Computes the starting zoom, which means applying the transformation. */ - private fun computeBaseZoom(): Float { + private fun computeTransformationZoom(): Float { when (mTransformation) { ZoomApi.TRANSFORMATION_CENTER_INSIDE -> { val scaleX = mContainerWidth / mContentScaledWidth val scaleY = mContainerHeight / mContentScaledHeight - LOG.v("computeBaseZoom", "centerInside", "scaleX:", scaleX, "scaleY:", scaleY) + LOG.v("computeTransformationZoom", "centerInside", "scaleX:", scaleX, "scaleY:", scaleY) return Math.min(scaleX, scaleY) } ZoomApi.TRANSFORMATION_CENTER_CROP -> { val scaleX = mContainerWidth / mContentScaledWidth val scaleY = mContainerHeight / mContentScaledHeight - LOG.v("computeBaseZoom", "centerCrop", "scaleX:", scaleX, "scaleY:", scaleY) + LOG.v("computeTransformationZoom", "centerCrop", "scaleX:", scaleX, "scaleY:", scaleY) return Math.max(scaleX, scaleY) } ZoomApi.TRANSFORMATION_NONE -> return 1f @@ -613,26 +623,36 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener /** * Computes the starting pan coordinates, given the current content dimensions and container - * dimensions. We will start from [0, 0], unless content is bigger than the container, in which - * case the transformation gravity should apply. - * - * Note: after this is computed and applied, the pan correction will take care of respecting the - * SmallerPolicy if needed. + * dimensions. This means applying the transformation gravity. */ @ScaledPan - private fun computeBasePan(): FloatArray { + private fun computeTransformationPan(): FloatArray { val result = floatArrayOf(0f, 0f) val extraWidth = mContentScaledWidth - mContainerWidth val extraHeight = mContentScaledHeight - mContainerHeight - if (extraWidth > 0) { // Got to change sign to have a negative result. - result[0] = -applyGravity(mTransformationGravity, extraWidth, true) - } - if (extraHeight > 0) { - result[1] = -applyGravity(mTransformationGravity, extraHeight, false) - } + val gravity = computeTransformationGravity(mTransformationGravity) + result[0] = -applyGravity(gravity, extraWidth, true) + result[1] = -applyGravity(gravity, extraHeight, false) return result } + /** + * Computes an actual [Gravity] value from the input gravity, + * which might also be [ZoomApi.TRANSFORMATION_GRAVITY_AUTO]. In this case we should + * try to infer a [Gravity] from the alignment, then fallback to center. + */ + @SuppressLint("RtlHardcoded") + private fun computeTransformationGravity(input: Int): Int { + return when (input) { + ZoomApi.TRANSFORMATION_GRAVITY_AUTO -> { + val horizontal = Alignment.toHorizontalGravity(mAlignment, Gravity.CENTER_HORIZONTAL) + val vertical = Alignment.toVerticalGravity(mAlignment, Gravity.CENTER_VERTICAL) + return horizontal or vertical + } + else -> input + } + } + /** * Returns 0 for 'start' gravities, [extraSpace] for 'end' gravities, and half of it * for 'center' gravities. @@ -649,7 +669,7 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener Gravity.TOP, Gravity.LEFT -> 0F Gravity.BOTTOM, Gravity.RIGHT -> extraSpace Gravity.CENTER_VERTICAL, Gravity.CENTER_HORIZONTAL -> 0.5F * extraSpace - else -> 0F // Can't happen + else -> 0F // Includes Gravity.NO_GRAVITY and unsupported mixes like FILL } } @@ -709,7 +729,7 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener @ScaledPan val fixY = checkPanBounds(false, allowOverScroll) if (fixX != 0f || fixY != 0f) { mMatrix.postTranslate(fixX, fixY) - mMatrix.mapRect(mTransformedRect, mContentRect) + mMatrix.mapRect(mContentScaledRect, mContentRect) } } @@ -735,6 +755,7 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener * * @return the pan correction to be applied to get into a valid state (0 if valid already) */ + @SuppressLint("RtlHardcoded") @ScaledPan private fun checkPanBounds(horizontal: Boolean, allowOverScroll: Boolean): Float { @ScaledPan val value = if (horizontal) scaledPanX else scaledPanY @@ -742,31 +763,26 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener @ScaledPan val contentSize = if (horizontal) mContentScaledWidth else mContentScaledHeight val overScrollable = if (horizontal) mOverScrollHorizontal else mOverScrollVertical @ScaledPan val overScroll = (if (overScrollable && allowOverScroll) maxOverScroll else 0).toFloat() + val alignmentGravity = if (horizontal) { + Alignment.toHorizontalGravity(mAlignment, Gravity.NO_GRAVITY) + } else { + Alignment.toVerticalGravity(mAlignment, Gravity.NO_GRAVITY) + } var min: Float var max: Float if (contentSize <= containerSize) { - // If content is smaller than container, act according to the smaller policy. + // If content is smaller than container, act according to the alignment. // Expect the output to be >= 0, we will show part of the container background. val extraSpace = containerSize - contentSize // > 0 - when (mSmallerPolicy) { - ZoomApi.SMALLER_POLICY_FROM_TRANSFORMATION -> { - // Apply the transformation gravity. - val correction = applyGravity(mTransformation, extraSpace, horizontal) - min = correction - max = correction - } - ZoomApi.SMALLER_POLICY_CENTER -> { - // Stay centered. Need a positive translation, that shows some background. - min = extraSpace / 2f - max = extraSpace / 2f - } - ZoomApi.SMALLER_POLICY_NONE -> { - // Everything is fine, just don't exit the container boundaries. - min = 0f - max = extraSpace - } - else -> throw IllegalStateException("Unsupported policy: $mSmallerPolicy") + if (alignmentGravity != Gravity.NO_GRAVITY) { + val correction = applyGravity(alignmentGravity, extraSpace, horizontal) + min = correction + max = correction + } else { + // This is Alignment.NONE or NO_VALUE. Don't force a value, just stay in the container boundaries. + min = 0F + max = extraSpace } } else { // If contentSize is bigger, we just don't want to go outside. @@ -1027,10 +1043,9 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener } override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { - if (!mFlingEnabled) { - // fling is disabled, so we just ignore the event - return false - } + // If disabled, don't start the gesture. + if (!mFlingEnabled) return false + if (!mHorizontalPanEnabled && !mVerticalPanEnabled) return false val vX = (if (mHorizontalPanEnabled) velocityX else 0F).toInt() val vY = (if (mVerticalPanEnabled) velocityY else 0F).toInt() @@ -1048,15 +1063,11 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener */ override fun onScroll(e1: MotionEvent, e2: MotionEvent, @AbsolutePan distanceX: Float, @AbsolutePan distanceY: Float): Boolean { + if (!mHorizontalPanEnabled && !mVerticalPanEnabled) return false - if (!mHorizontalPanEnabled && !mVerticalPanEnabled) { - return false - } - - var delta = AbsolutePoint(distanceX, distanceY) if (setState(SCROLLING)) { // Change sign, since we work with opposite values. - delta = -delta + val delta = AbsolutePoint(-distanceX, -distanceY) // See if we are overscrolling. val panFix = mCurrentPanCorrection @@ -1360,7 +1371,9 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener } val newZoom = it.getAnimatedValue("zoom") as Float val currentPan = it.getAnimatedValue("pan") as AbsolutePoint - applyZoomAndAbsolutePan(newZoom, currentPan.x, currentPan.y, allowOverScroll, allowOverPinch, zoomTargetX, zoomTargetY) + applyZoomAndAbsolutePan(newZoom, currentPan.x, currentPan.y, + allowOverScroll, allowOverPinch, + zoomTargetX, zoomTargetY) } animator.start() } @@ -1415,15 +1428,13 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener private fun applyZoom(@Zoom zoom: Float, allowOverPinch: Boolean, allowOverScroll: Boolean = false, - zoomTargetX: Float? = null, - zoomTargetY: Float? = null, + zoomTargetX: Float = mContainerWidth / 2f, + zoomTargetY: Float = mContainerHeight / 2f, notifyListeners: Boolean = true) { val newZoom = checkZoomBounds(zoom, allowOverPinch) val scaleFactor = newZoom / this.zoom - - mMatrix.postScale(scaleFactor, scaleFactor, - zoomTargetX ?: mContainerWidth / 2f, zoomTargetY ?: mContainerHeight / 2f) - mMatrix.mapRect(mTransformedRect, mContentRect) + mMatrix.postScale(scaleFactor, scaleFactor, zoomTargetX, zoomTargetY) + mMatrix.mapRect(mContentScaledRect, mContentRect) this.zoom = newZoom ensurePanBounds(allowOverScroll) if (notifyListeners) { @@ -1458,7 +1469,7 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener // Translation val delta = AbsolutePoint(x, y) - pan mMatrix.preTranslate(delta.x, delta.y) - mMatrix.mapRect(mTransformedRect, mContentRect) + mMatrix.mapRect(mContentScaledRect, mContentRect) // Scale val newZoom = checkZoomBounds(zoom, allowOverPinch) @@ -1467,12 +1478,10 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener // mMatrix.postScale(scaleFactor, scaleFactor, getScaledPanX(), getScaledPanY()); // It keeps the pivot point at the scaled values 0, 0 (see applyPinch). // I think we should keep the current top, left.. Let's try: - val pivotX = zoomTargetX ?: 0F val pivotY = zoomTargetY ?: 0F - mMatrix.postScale(scaleFactor, scaleFactor, pivotX, pivotY) - mMatrix.mapRect(mTransformedRect, mContentRect) + mMatrix.mapRect(mContentScaledRect, mContentRect) this.zoom = newZoom ensurePanBounds(allowOverScroll) @@ -1493,7 +1502,7 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener */ private fun applyScaledPan(@ScaledPan deltaX: Float, @ScaledPan deltaY: Float, allowOverScroll: Boolean) { mMatrix.postTranslate(deltaX, deltaY) - mMatrix.mapRect(mTransformedRect, mContentRect) + mMatrix.mapRect(mContentScaledRect, mContentRect) ensurePanBounds(allowOverScroll) dispatchOnMatrix() } @@ -1507,32 +1516,36 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener // while max values are related to top-left. private fun computeScrollerValues(horizontal: Boolean, output: ScrollerValues) { @ScaledPan val currentPan = (if (horizontal) scaledPanX else scaledPanY).toInt() - val viewDim = (if (horizontal) mContainerWidth else mContainerHeight).toInt() + val containerDim = (if (horizontal) mContainerWidth else mContainerHeight).toInt() @ScaledPan val contentDim = (if (horizontal) mContentScaledWidth else mContentScaledHeight).toInt() val fix = checkPanBounds(horizontal, false).toInt() - if (viewDim >= contentDim) { - // Content is smaller, we are showing some boundary. - // We can't move in any direction (but we can overScroll). - output.minValue = currentPan + fix - output.startValue = currentPan - output.maxValue = currentPan + fix - } else { - // Content is bigger, we can move. - // in this case minPan + viewDim = contentDim - output.minValue = -(contentDim - viewDim) - output.startValue = currentPan + val alignment = if (horizontal) Alignment.getHorizontal(mAlignment) else Alignment.getVertical(mAlignment) + if (contentDim > containerDim) { + // Content is bigger. We can move between 0 and extraSpace, but since our pans + // are negative, we must invert the sign. + val extraSpace = contentDim - containerDim + output.minValue = -extraSpace output.maxValue = 0 + } else if (Alignment.isNone(alignment)) { + // Content is free to be moved, although smaller than the container. We can move + // between 0 and extraSpace (and when content is smaller, pan is positive). + val extraSpace = containerDim - contentDim + output.minValue = 0 + output.maxValue = extraSpace + } else { + // Content can't move in this dimensions. Go back to the correct value. + val finalValue = currentPan + fix + output.minValue = finalValue + output.maxValue = finalValue } + output.startValue = currentPan output.isInOverScroll = fix != 0 } private class ScrollerValues { - @ScaledPan - internal var minValue: Int = 0 - @ScaledPan - internal var startValue: Int = 0 - @ScaledPan - internal var maxValue: Int = 0 + @ScaledPan internal var minValue: Int = 0 + @ScaledPan internal var startValue: Int = 0 + @ScaledPan internal var maxValue: Int = 0 internal var isInOverScroll: Boolean = false } @@ -1649,7 +1662,7 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener private fun Float.toZoom(@ZoomType inputZoomType: Int): Float { when (inputZoomType) { ZoomApi.TYPE_ZOOM -> return this - ZoomApi.TYPE_REAL_ZOOM -> return this / mBaseZoom + ZoomApi.TYPE_REAL_ZOOM -> return this / mTransformationZoom } throw IllegalArgumentException("Unknown ZoomType $inputZoomType") } @@ -1660,7 +1673,7 @@ internal constructor(context: Context) : ViewTreeObserver.OnGlobalLayoutListener @RealZoom private fun Float.toRealZoom(@ZoomType inputZoomType: Int): Float { when (inputZoomType) { - ZoomApi.TYPE_ZOOM -> return this * mBaseZoom + ZoomApi.TYPE_ZOOM -> return this * mTransformationZoom ZoomApi.TYPE_REAL_ZOOM -> return this } throw IllegalArgumentException("Unknown ZoomType $inputZoomType") diff --git a/library/src/main/java/com/otaliastudios/zoom/ZoomImageView.kt b/library/src/main/java/com/otaliastudios/zoom/ZoomImageView.kt index 89ac09d..d809210 100644 --- a/library/src/main/java/com/otaliastudios/zoom/ZoomImageView.kt +++ b/library/src/main/java/com/otaliastudios/zoom/ZoomImageView.kt @@ -47,20 +47,20 @@ private constructor(context: Context, attrs: AttributeSet?, @AttrRes defStyleAtt val zoomEnabled = a.getBoolean(R.styleable.ZoomEngine_zoomEnabled, true) val flingEnabled = a.getBoolean(R.styleable.ZoomEngine_flingEnabled, true) val allowFlingInOverscroll = a.getBoolean(R.styleable.ZoomEngine_allowFlingInOverscroll, true) - val minZoom = a.getFloat(R.styleable.ZoomEngine_minZoom, -1f) - val maxZoom = a.getFloat(R.styleable.ZoomEngine_maxZoom, -1f) - @ZoomType val minZoomMode = a.getInteger(R.styleable.ZoomEngine_minZoomType, ZoomApi.TYPE_ZOOM) - @ZoomType val maxZoomMode = a.getInteger(R.styleable.ZoomEngine_maxZoomType, ZoomApi.TYPE_ZOOM) + val minZoom = a.getFloat(R.styleable.ZoomEngine_minZoom, ZoomApi.MIN_ZOOM_DEFAULT) + val maxZoom = a.getFloat(R.styleable.ZoomEngine_maxZoom, ZoomApi.MAX_ZOOM_DEFAULT) + @ZoomType val minZoomMode = a.getInteger(R.styleable.ZoomEngine_minZoomType, ZoomApi.MIN_ZOOM_DEFAULT_TYPE) + @ZoomType val maxZoomMode = a.getInteger(R.styleable.ZoomEngine_maxZoomType, ZoomApi.MAX_ZOOM_DEFAULT_TYPE) val transformation = a.getInteger(R.styleable.ZoomEngine_transformation, ZoomApi.TRANSFORMATION_CENTER_INSIDE) - val transformationGravity = a.getInt(R.styleable.ZoomEngine_transformationGravity, Gravity.CENTER) - val smallerPolicy = a.getInt(R.styleable.ZoomEngine_smallerPolicy, ZoomApi.SMALLER_POLICY_CENTER) + val transformationGravity = a.getInt(R.styleable.ZoomEngine_transformationGravity, ZoomApi.TRANSFORMATION_GRAVITY_AUTO) + val alignment = a.getInt(R.styleable.ZoomEngine_alignment, ZoomApi.ALIGNMENT_DEFAULT) val animationDuration = a.getInt(R.styleable.ZoomEngine_animationDuration, ZoomEngine.DEFAULT_ANIMATION_DURATION.toInt()).toLong() a.recycle() engine.setContainer(this) engine.addListener(this) setTransformation(transformation, transformationGravity) - setSmallerPolicy(smallerPolicy) + setAlignment(alignment) setOverScrollHorizontal(overScrollHorizontal) setOverScrollVertical(overScrollVertical) setHorizontalPanEnabled(horizontalPanEnabled) @@ -70,8 +70,8 @@ private constructor(context: Context, attrs: AttributeSet?, @AttrRes defStyleAtt setFlingEnabled(flingEnabled) setAllowFlingInOverscroll(allowFlingInOverscroll) setAnimationDuration(animationDuration) - if (minZoom > -1) setMinZoom(minZoom, minZoomMode) - if (maxZoom > -1) setMaxZoom(maxZoom, maxZoomMode) + setMinZoom(minZoom, minZoomMode) + setMaxZoom(maxZoom, maxZoomMode) imageMatrix = mMatrix scaleType = ImageView.ScaleType.MATRIX diff --git a/library/src/main/java/com/otaliastudios/zoom/ZoomLayout.kt b/library/src/main/java/com/otaliastudios/zoom/ZoomLayout.kt index fb1f7ed..a43a24a 100644 --- a/library/src/main/java/com/otaliastudios/zoom/ZoomLayout.kt +++ b/library/src/main/java/com/otaliastudios/zoom/ZoomLayout.kt @@ -53,20 +53,20 @@ private constructor(context: Context, attrs: AttributeSet?, @AttrRes defStyleAtt val flingEnabled = a.getBoolean(R.styleable.ZoomEngine_flingEnabled, true) val allowFlingInOverscroll = a.getBoolean(R.styleable.ZoomEngine_allowFlingInOverscroll, true) val hasChildren = a.getBoolean(R.styleable.ZoomEngine_hasClickableChildren, false) - val minZoom = a.getFloat(R.styleable.ZoomEngine_minZoom, -1f) - val maxZoom = a.getFloat(R.styleable.ZoomEngine_maxZoom, -1f) - @ZoomType val minZoomMode = a.getInteger(R.styleable.ZoomEngine_minZoomType, ZoomApi.TYPE_ZOOM) - @ZoomType val maxZoomMode = a.getInteger(R.styleable.ZoomEngine_maxZoomType, ZoomApi.TYPE_ZOOM) + val minZoom = a.getFloat(R.styleable.ZoomEngine_minZoom, ZoomApi.MIN_ZOOM_DEFAULT) + val maxZoom = a.getFloat(R.styleable.ZoomEngine_maxZoom, ZoomApi.MAX_ZOOM_DEFAULT) + @ZoomType val minZoomMode = a.getInteger(R.styleable.ZoomEngine_minZoomType, ZoomApi.MIN_ZOOM_DEFAULT_TYPE) + @ZoomType val maxZoomMode = a.getInteger(R.styleable.ZoomEngine_maxZoomType, ZoomApi.MAX_ZOOM_DEFAULT_TYPE) val transformation = a.getInteger(R.styleable.ZoomEngine_transformation, ZoomApi.TRANSFORMATION_CENTER_INSIDE) - val transformationGravity = a.getInt(R.styleable.ZoomEngine_transformationGravity, Gravity.CENTER) - val smallerPolicy = a.getInt(R.styleable.ZoomEngine_smallerPolicy, ZoomApi.SMALLER_POLICY_CENTER) + val transformationGravity = a.getInt(R.styleable.ZoomEngine_transformationGravity, ZoomApi.TRANSFORMATION_GRAVITY_AUTO) + val alignment = a.getInt(R.styleable.ZoomEngine_alignment, ZoomApi.ALIGNMENT_DEFAULT) val animationDuration = a.getInt(R.styleable.ZoomEngine_animationDuration, ZoomEngine.DEFAULT_ANIMATION_DURATION.toInt()).toLong() a.recycle() engine.setContainer(this) engine.addListener(this) setTransformation(transformation, transformationGravity) - setSmallerPolicy(smallerPolicy) + setAlignment(alignment) setOverScrollHorizontal(overScrollHorizontal) setOverScrollVertical(overScrollVertical) setHorizontalPanEnabled(horizontalPanEnabled) @@ -76,8 +76,8 @@ private constructor(context: Context, attrs: AttributeSet?, @AttrRes defStyleAtt setFlingEnabled(flingEnabled) setAllowFlingInOverscroll(allowFlingInOverscroll) setAnimationDuration(animationDuration) - if (minZoom > -1) setMinZoom(minZoom, minZoomMode) - if (maxZoom > -1) setMaxZoom(maxZoom, maxZoomMode) + setMinZoom(minZoom, minZoomMode) + setMaxZoom(maxZoom, maxZoomMode) setHasClickableChildren(hasChildren) setWillNotDraw(false) diff --git a/library/src/main/res/values/attrs.xml b/library/src/main/res/values/attrs.xml index 0d8038d..c879d93 100644 --- a/library/src/main/res/values/attrs.xml +++ b/library/src/main/res/values/attrs.xml @@ -26,6 +26,7 @@ + @@ -34,10 +35,17 @@ - - - - + + + + + + + + + + +