Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Android - ImageView bottomCrop instead of centerCrop

I'm trying to position an ImageView so that the bottom of the image is always pinned to the bottom of the view, no matter how small the height of the ImageView is. However, none of the scale types seem to fit what I am trying to do. CenterCrop is close, but I don't want the image to be centered. Similar to how CSS would handle absolute positioning.

The reason is, I need to animate the height of the ImageView, but make it seem as though it is "revealing" the upper portion of the image. I assume figuring out this method of cropping the image and animating the ImageView height is the easiest way to do this, but if someone knows of a better way I'd love being pointed in the right direction.

Any help appreciated.

like image 287
Jpoliachik Avatar asked Sep 23 '13 05:09

Jpoliachik


4 Answers

Jpoliachik's answer was cool enough to make me wanna generalize it to support both top/bottom and left/right, by a variable amount. :) Now to top crop, just call setCropOffset(0,0) , bottom crop setCropOffset(0,1), left crop is also setCropOffset(0,0), and right crop setCropOffset(1,0). If you want to offset the viewport by some fraction of the image in one dimension, you can call e.g. setCropOffset(0, 0.25f) to shift it down by 25% of the non-viewable space, while 0.5f would center it. Cheers!

/**
 * {@link android.widget.ImageView} that supports directional cropping in both vertical and
 * horizontal directions instead of being restricted to center-crop. Automatically sets {@link
 * android.widget.ImageView.ScaleType} to MATRIX and defaults to center-crop.
 */
public class CropImageView extends android.support.v7.widget.AppCompatImageView {
    private static final float DEFAULT_HORIZONTAL_OFFSET = 0.5f;
    private static final float DEFAULT_VERTICAL_OFFSET = 0.5f;

    private float mHorizontalOffsetPercent = DEFAULT_HORIZONTAL_OFFSET;
    private float mVerticalOffsetPercent = DEFAULT_VERTICAL_OFFSET;

    public CropImageView(Context context) {
        this(context, null);
    }

    public CropImageView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CropImageView(Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setScaleType(ScaleType.MATRIX);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        applyCropOffset();
    }

    /**
     * Sets the crop box offset by the specified percentage values. For example, a center-crop would
     * be (0.5, 0.5), a top-left crop would be (0, 0), and a bottom-center crop would be (0.5, 1)
     */
    public void setCropOffset(float horizontalOffsetPercent, float verticalOffsetPercent) {
        if (mHorizontalOffsetPercent < 0
                || mVerticalOffsetPercent < 0
                || mHorizontalOffsetPercent > 1
                || mVerticalOffsetPercent > 1) {
            throw new IllegalArgumentException("Offset values must be a float between 0.0 and 1.0");
        }

        mHorizontalOffsetPercent = horizontalOffsetPercent;
        mVerticalOffsetPercent = verticalOffsetPercent;
        applyCropOffset();
    }

    private void applyCropOffset() {
        Matrix matrix = getImageMatrix();

        float scale;
        int viewWidth = getWidth() - getPaddingLeft() - getPaddingRight();
        int viewHeight = getHeight() - getPaddingTop() - getPaddingBottom();
        int drawableWidth = 0, drawableHeight = 0;
        // Allow for setting the drawable later in code by guarding ourselves here.
        if (getDrawable() != null) {
            drawableWidth = getDrawable().getIntrinsicWidth();
            drawableHeight = getDrawable().getIntrinsicHeight();
        }

        // Get the scale.
        if (drawableWidth * viewHeight > drawableHeight * viewWidth) {
            // Drawable is flatter than view. Scale it to fill the view height.
            // A Top/Bottom crop here should be identical in this case.
            scale = (float) viewHeight / (float) drawableHeight;
        } else {
            // Drawable is taller than view. Scale it to fill the view width.
            // Left/Right crop here should be identical in this case.
            scale = (float) viewWidth / (float) drawableWidth;
        }

        float viewToDrawableWidth = viewWidth / scale;
        float viewToDrawableHeight = viewHeight / scale;
        float xOffset = mHorizontalOffsetPercent * (drawableWidth - viewToDrawableWidth);
        float yOffset = mVerticalOffsetPercent * (drawableHeight - viewToDrawableHeight);

        // Define the rect from which to take the image portion.
        RectF drawableRect =
                new RectF(
                        xOffset,
                        yOffset,
                        xOffset + viewToDrawableWidth,
                        yOffset + viewToDrawableHeight);
        RectF viewRect = new RectF(0, 0, viewWidth, viewHeight);
        matrix.setRectToRect(drawableRect, viewRect, Matrix.ScaleToFit.FILL);

        setImageMatrix(matrix);
    }
}
like image 132
qix Avatar answered Nov 12 '22 02:11

qix


I ended up subclassing ImageView and creating a way to enable a 'BottomCrop' type image scaling.

I assigned the image to a RectF of the correct size by calculating the scale and expected image height based on the view height.

public class BottomCropImage extends ImageView {

public BottomCropImage(Context context) {
    super(context);
    setup();
}

public BottomCropImage(Context context, AttributeSet attrs) {
    super(context, attrs);
    setup();
}

public BottomCropImage(Context context, AttributeSet attrs,
        int defStyle) {
    super(context, attrs, defStyle);
    setup();
}

private void setup() {
    setScaleType(ScaleType.MATRIX);
}

@Override
protected boolean setFrame(int l, int t, int r, int b) {
    Matrix matrix = getImageMatrix();

    float scale;
    int viewWidth = getWidth() - getPaddingLeft() - getPaddingRight();
    int viewHeight = getHeight() - getPaddingTop() - getPaddingBottom();
    int drawableWidth = getDrawable().getIntrinsicWidth();
    int drawableHeight = getDrawable().getIntrinsicHeight();

    //Get the scale 
    if (drawableWidth * viewHeight > drawableHeight * viewWidth) {
        scale = (float) viewHeight / (float) drawableHeight;
    } else {
        scale = (float) viewWidth / (float) drawableWidth;
    }

    //Define the rect to take image portion from
    RectF drawableRect = new RectF(0, drawableHeight - (viewHeight / scale), drawableWidth, drawableHeight);
    RectF viewRect = new RectF(0, 0, viewWidth, viewHeight);
    matrix.setRectToRect(drawableRect, viewRect, Matrix.ScaleToFit.FILL);


    setImageMatrix(matrix);

    return super.setFrame(l, t, r, b);
}        

}
like image 31
Jpoliachik Avatar answered Nov 12 '22 02:11

Jpoliachik


I used @Jpoliachik code and it worked good, I made a couple of tweaks because sometimes getWidth and getHeight were returning 0 - getMeasuredWidth and getMeasuredHeight solved the problem.

@Override
protected boolean setFrame(int l, int t, int r, int b) {
   if (getDrawable() == null)
       return super.setFrame(l, t, r, b);

   Matrix matrix = getImageMatrix();

   float scale;
   int viewWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
   int viewHeight = getMeasuredHeight() - getPaddingTop() - getPaddingBottom();
   int drawableWidth = getDrawable().getIntrinsicWidth();
   int drawableHeight = getDrawable().getIntrinsicHeight();
   //Get the scale
   if (drawableWidth * viewHeight > drawableHeight * viewWidth) {
       scale = (float) viewHeight / (float) drawableHeight;
   } else {
       scale = (float) viewWidth / (float) drawableWidth;
   }

   //Define the rect to take image portion from
   RectF drawableRect = new RectF(0, drawableHeight - (viewHeight / scale), drawableWidth, drawableHeight);
   RectF viewRect = new RectF(0, 0, viewWidth, viewHeight);
   matrix.setRectToRect(drawableRect, viewRect, Matrix.ScaleToFit.FILL);

   setImageMatrix(matrix);

   return super.setFrame(l, t, r, b);
}
like image 16
Alessandro Roaro Avatar answered Nov 12 '22 04:11

Alessandro Roaro


Based on qix's answer I've made a few improvements:

  1. Created custom XML attributes. You don't have to call setCropOffset(). Instead you can just add app:verticalCropOffset and app:horizontalCropOffset to your XML layout (accepts both fractions and floats).
  2. Addedapp:offsetScaleType attribute to control how the image is scaled:
    • crop: the same behavior as in the original answer, i. e. the image is scaled so that both dimensions of the image will be equal to or larger than the corresponding dimension of the view; app:horizontalCropOffset and app:verticalCropOffset are then applied
    • fitInside: image is scaled so that both dimensions of the image will be equal to or less than the corresponding dimension of the view; app:horizontalFitOffset and app:verticalFitOffset are then applied
    • fitX: image is scaled so that its X dimension is equal to the view's X dimension. Y dimension is scaled so that the ratio is preserved. If image's Y dimension is larger than view's dimension, app:verticalCropOffset is applied, otherwise app:verticalFitOffset is applied
    • fitY: image is scaled so that its Y dimension is equal to the view's Y dimension. X dimension is scaled so that the ratio is preserved. If image's X dimension is larger than view's dimension, app:horizontalCropOffset is applied, otherwise app:horizontalFitOffset is applied
  3. Converted code to Kotlin
  4. Few minor refactorings for better Kotlin readability

We have to add a new OffsetImageView styleable to our attrs.xml:

<declare-styleable name="OffsetImageView">
    <attr name="horizontalFitOffset" format="float|fraction" />
    <attr name="verticalFitOffset" format="float|fraction" />
    <attr name="horizontalCropOffset" format="float|fraction" />
    <attr name="verticalCropOffset" format="float|fraction" />
    <attr name="offsetScaleType" format="enum">
        <enum name="crop" value="0"/>
        <enum name="fitInside" value="1"/>
        <enum name="fitX" value="2"/>
        <enum name="fitY" value="3"/>
    </attr>
</declare-styleable>

OffsetImageView code (add your own package and import your module's R file):

import android.content.Context
import android.content.res.TypedArray
import android.graphics.Matrix
import android.graphics.RectF
import android.util.AttributeSet
import androidx.annotation.AttrRes
import androidx.annotation.StyleableRes
import androidx.appcompat.widget.AppCompatImageView


/**
 * [android.widget.ImageView] that supports directional cropping in both vertical and
 * horizontal directions instead of being restricted to center-crop. Automatically sets [ ] to MATRIX and defaults to center-crop.
 *
 * XML attributes (for offsets either a float or a fraction is allowed in values, e. g. 50% or 0.5):
 * - app:verticalCropOffset
 * - app:horizontalCropOffset
 * - app:verticalFitOffset
 * - app:horizontalFitOffset
 * - app:offsetScaleType
 *
 * The `app:offsetScaleType` accepts one of the enum values:
 * - crop: the same behavior as in the original answer, i. e. the image is scaled so that both dimensions of the image will be equal to or larger than the corresponding dimension of the view; `app:horizontalCropOffset` and `app:verticalCropOffset` are then applied
 * - fitInside: image is scaled so that both dimensions of the image will be equal to or less than the corresponding dimension of the view; `app:horizontalFitOffset` and `app:verticalFitOffset` are then applied
 * - fitX: image is scaled so that its X dimension is equal to the view's X dimension. Y dimension is scaled so that the ratio is preserved. If image's Y dimension is larger than view's dimension, `app:verticalCropOffset` is applied, otherwise `app:verticalFitOffset` is applied
 * - fitY: image is scaled so that its Y dimension is equal to the view's Y dimension. X dimension is scaled so that the ratio is preserved. If image's X dimension is larger than view's dimension, `app:horizontalCropOffset` is applied, otherwise `app:horizontalFitOffset` is applied
 */
class OffsetImageView(context: Context, attrs: AttributeSet?, @AttrRes defStyleAttr: Int) : AppCompatImageView(context, attrs, defStyleAttr) {
    companion object {
        private const val DEFAULT_HORIZONTAL_OFFSET = 0.5f
        private const val DEFAULT_VERTICAL_OFFSET = 0.5f
    }

    enum class OffsetScaleType(val code: Int) {
        CROP(0), FIT_INSIDE(1), FIT_X(2), FIT_Y(3)
    }

    private var mHorizontalCropOffsetPercent = DEFAULT_HORIZONTAL_OFFSET
    private var mHorizontalFitOffsetPercent = DEFAULT_HORIZONTAL_OFFSET
    private var mVerticalCropOffsetPercent = DEFAULT_VERTICAL_OFFSET
    private var mVerticalFitOffsetPercent = DEFAULT_VERTICAL_OFFSET
    private var mOffsetScaleType = OffsetScaleType.CROP

    init {
        scaleType = ScaleType.MATRIX
        if (attrs != null) {
            val a = context.obtainStyledAttributes(attrs, R.styleable.OffsetImageView, defStyleAttr, 0)

            readAttrFloatValueIfSet(a, R.styleable.OffsetImageView_verticalCropOffset)?.let {
                mVerticalCropOffsetPercent = it
            }
            readAttrFloatValueIfSet(a, R.styleable.OffsetImageView_horizontalCropOffset)?.let {
                mHorizontalCropOffsetPercent = it
            }
            readAttrFloatValueIfSet(a, R.styleable.OffsetImageView_verticalFitOffset)?.let {
                mVerticalFitOffsetPercent = it
            }
            readAttrFloatValueIfSet(a, R.styleable.OffsetImageView_horizontalFitOffset)?.let {
                mHorizontalFitOffsetPercent = it
            }
            with (a) {
                if (hasValue(R.styleable.OffsetImageView_offsetScaleType)) {
                    val code = getInt(R.styleable.OffsetImageView_offsetScaleType, -1)
                    if (code != -1) {
                        OffsetScaleType.values().find {
                            it.code == code
                        }?.let {
                            mOffsetScaleType = it
                        }
                    }
                }
            }

            a.recycle()
        }
    }

    constructor(context: Context) : this(context, null)
    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        applyOffset()
    }

    private fun readAttrFloatValueIfSet(typedArray: TypedArray, @StyleableRes index: Int): Float? {
        try {
            with(typedArray) {
                if (!hasValue(index)) return null
                var value = getFloat(index, -1f)
                if (value >= 0) return value

                value = getFraction(index, 1, 1, -1f)
                if (value >= 0) return value

                return null
            }
        } catch (e: RuntimeException) {
            e.printStackTrace()
            return null
        }
    }

    /**
     * Sets the crop box offset by the specified percentage values. For example, a center-crop would
     * be (0.5, 0.5), a top-left crop would be (0, 0), and a bottom-center crop would be (0.5, 1)
     */
    fun setOffsets(horizontalCropOffsetPercent: Float,
                   verticalCropOffsetPercent: Float,
                   horizontalFitOffsetPercent: Float,
                   verticalFitOffsetPercent: Float,
                   scaleType: OffsetScaleType) {
        require(!(mHorizontalCropOffsetPercent < 0
                || mVerticalCropOffsetPercent < 0
                || mHorizontalFitOffsetPercent < 0
                || mVerticalFitOffsetPercent < 0
                || mHorizontalCropOffsetPercent > 1
                || mVerticalCropOffsetPercent > 1
                || mHorizontalFitOffsetPercent > 1
                || mVerticalFitOffsetPercent > 1)) { "Offset values must be a float between 0.0 and 1.0" }
        mHorizontalCropOffsetPercent = horizontalCropOffsetPercent
        mVerticalCropOffsetPercent = verticalCropOffsetPercent
        mHorizontalFitOffsetPercent = horizontalFitOffsetPercent
        mVerticalFitOffsetPercent = verticalFitOffsetPercent
        mOffsetScaleType = scaleType
        applyOffset()
    }

    private fun applyOffset() {
        val matrix: Matrix = imageMatrix
        val scale: Float
        val viewWidth: Int = width - paddingLeft - paddingRight
        val viewHeight: Int = height - paddingTop - paddingBottom
        val drawable = drawable
        val drawableWidth: Int
        val drawableHeight: Int

        if (drawable == null) {
            drawableWidth = 0
            drawableHeight = 0
        } else {
            // Allow for setting the drawable later in code by guarding ourselves here.
            drawableWidth = drawable.intrinsicWidth
            drawableHeight = drawable.intrinsicHeight
        }

        val scaleHeight = when (mOffsetScaleType) {
            OffsetScaleType.CROP -> drawableWidth * viewHeight > drawableHeight * viewWidth // If drawable is flatter than view, scale it to fill the view height.
            OffsetScaleType.FIT_INSIDE -> drawableWidth * viewHeight < drawableHeight * viewWidth // If drawable is is taller than view, scale according to height to fit inside.
            OffsetScaleType.FIT_X -> false // User wants to fit X axis -> scale according to width
            OffsetScaleType.FIT_Y -> true // User wants to fit Y axis -> scale according to height
        }
        // Get the scale.
        scale = if (scaleHeight) {
            viewHeight.toFloat() / drawableHeight.toFloat()
        } else {
            viewWidth.toFloat() / drawableWidth.toFloat()
        }
        val viewToDrawableWidth = viewWidth / scale
        val viewToDrawableHeight = viewHeight / scale

        if (drawableWidth >= viewToDrawableWidth && drawableHeight >= viewToDrawableHeight) {
            val xOffset = mHorizontalCropOffsetPercent * (drawableWidth - viewToDrawableWidth)
            val yOffset = mVerticalCropOffsetPercent * (drawableHeight - viewToDrawableHeight)

            // Define the rect from which to take the image portion.
                val drawableRect = RectF(
                        xOffset,
                        yOffset,
                        xOffset + viewToDrawableWidth,
                        yOffset + viewToDrawableHeight)
                val viewRect = RectF(0f, 0f, viewWidth.toFloat(), viewHeight.toFloat())
                matrix.setRectToRect(drawableRect, viewRect, Matrix.ScaleToFit.FILL)
        } else {
            val xOffset = mHorizontalFitOffsetPercent * (viewToDrawableWidth - drawableWidth) * scale
            val yOffset = mVerticalFitOffsetPercent * (viewToDrawableHeight - drawableHeight) * scale

            val drawableRect = RectF(
                    0f,
                    0f,
                    drawableWidth.toFloat(),
                    drawableHeight.toFloat())
            val viewRect = RectF(xOffset, yOffset, xOffset + drawableWidth * scale, yOffset + drawableHeight * scale)
            matrix.setRectToRect(drawableRect, viewRect, Matrix.ScaleToFit.FILL)
        }
        imageMatrix = matrix
    }
}

Use in your layout as follows:

<your.package.OffsetImageView
    android:id="@+id/image"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:src="@drawable/image"
    app:verticalFitOffset="0.3"
    app:horizontalFitOffset="70%"
    app:offsetScaleType="fitInside" />
like image 6
Miloš Černilovský Avatar answered Nov 12 '22 02:11

Miloš Černilovský