Custom image manipulation (crop and translate) at load time with Glide on Android

Gary Chang
DigIO Australia

--

At DigIO we have a requirement in an Android app to load very large product images from the resources folder, cropping and shifting them horizontally and vertically, all whilst scrolling smoothly even on older, resource constrained Android devices.

Using images that are contained inside the app is not ideal nor recommended but a necessity as the app was released before the server side, content management solution to hold images and other assets, was even at the design stage. To minimise app download size, the app deploys to the Google Play store as an app bundle so that users only download the app with images for their device’s screen pixel density, and the images are compressed using the WEBP format for the best compression at acceptable levels of image quality.

The Glide library is recommended by the Android development team to load images off the main UI thread (to minimise screen jank) and is very flexible. However in this particular scenario, the officially documented ways of cropping the image AFTER it has already been decoded into a (huge) bitmap using transformations, was not suitable. The product images are about a 1000 pixels in width and about double that in height, so the simple act of creating a temporary bitmap, then cropping it using a Glide transformation (such as detailed here): can cause lower end devices to run out of memory even with Glide’s bitmap caching enabled. Processing the image even later in the Glide pipeline yields the same out of memory results.

Finding a solution towards the start of the Glide loading pipeline was needed.

With the samples that came with Glide and on the Interwebs, it wasn’t clear how custom image manipulation (such as cropping and translation) could be performed. This blog is the result of research into this particular area of Glide, namely the use of a custom Glide model and loader.

The demonstration app is call Product Concepts (github: GlideCropper). The full source code for this can be found here.

Glide custom model - CroppedImage

At the heart of it, in our app, we want to display (and thus image decode) only a small region of a very large image resource file. The result is typically a short and wide letterbox image for a list screen (but many of them in a list, with row recycling), and a single, wider and taller image for a detail screen. We want the resulting bitmap height and width to match that of the ImageView’s dimensions, not the file’s dimensions, with no image scaling, resizing, nor downsampling.

Glide provides subclasses of ResourceDecoder<SomeInputSource, SomeOutputDrawable> such as ResourceBitmapDecoder, but none of the provided decoders in the library appeared to be suitable to extend from. We also wanted to continue loading smaller images using Glide in the normal way (ie. with no cropping) so there was a need to have a way of specifying to Glide that sometimes we want a cropped image and other times, normally processed images.

In order to meet these requirements, we need to implement a custom Glide model and related classes. Detailed instructions on how to do this can be found here.

For our needs, we create the following classes:

Model class: CroppedImage — this is a POKO that describes the drawable (WEBP) resource ID to be loaded and the ImageView’s actual dimensions after layout, together with any horizontal or vertical offsets for translating the image.

class CroppedImage(
val resId: Int,
val viewWidth: Int,
val viewHeight: Int,
val horizontalOffset: Int = 0,
val verticalOffset: Int = 0
) {

// Methods needed for Glide caching.
override fun equals(other: Any?): Boolean {
if (other is CroppedImage) {
return resId == other.resId
&& viewWidth == other.viewWidth
&& viewHeight == other.viewHeight
&& horizontalOffset == other.horizontalOffset
&& verticalOffset == other.verticalOffset
}
return this === other
}

override fun hashCode(): Int {
return (resId.toString() +
viewWidth.toString() +
viewHeight.toString() +
horizontalOffset.toString() +
verticalOffset.toString()).hashCode()
}
}

Model loader class: CroppedImageModelLoader — this loader understands how to load a CroppedImage.

class CroppedImageModelLoader(val resources: Resources) : ModelLoader<CroppedImage, CroppedImageDecoderInput> {

override fun handles(model: CroppedImage) = true
override fun buildLoadData(
model: CroppedImage,
width: Int,
height: Int,
options: Options): ModelLoader.LoadData<CroppedImageDecoderInput>? {

return ModelLoader.LoadData<CroppedImageDecoderInput>(
ObjectKey(model),
CroppedImageDataFetcher(resources, model))
}
}

Model loader factory class: CroppedImageModelLoaderFactory knows how to create CroppedImageModelLoader instances.

class CroppedImageModelLoaderFactory(private val resources: Resources) :
ModelLoaderFactory<CroppedImage, CroppedImageDecoderInput> {

override fun build(multiFactory: MultiModelLoaderFactory):
ModelLoader<CroppedImage, CroppedImageDecoderInput> = CroppedImageModelLoader(resources)

override fun teardown() { }
}

Data fetcher class: CroppedImageDataFetcher — this data fetcher class knows how to create data input for the decoder, and is controlled by the model loader.

class CroppedImageDataFetcher(
val resources: Resources,
val model: CroppedImage) : DataFetcher<CroppedImageDecoderInput> {

override fun getDataClass(): Class<CroppedImageDecoderInput> = CroppedImageDecoderInput::class.java

override fun getDataSource(): DataSource = DataSource.LOCAL

override fun
loadData(priority: Priority, callback: DataFetcher.DataCallback<in CroppedImageDecoderInput>) {
val intermediate = CroppedImageDecoderInput(
resId = model.resId,
viewHeight = model.viewHeight,
viewWidth = model.viewWidth,
verticalOffset = model.verticalOffset,
horizontalOffset = model.horizontalOffset
)
callback.onDataReady(intermediate)
}

override fun cancel() { }

override fun cleanup() { }
}

ResourceDecoder class: CroppedBitmapDecoder — this is the heart of the customisation that allows us to manipulate the image as the WEBP image file is being decoded. All the other classes mentioned here are effectively boilerplate code to enable this decoder to do its work within the Glide framework.

Here we specify the first decoding to be just for the bounds to determine the image file’s height and width. This is followed by calculating the crop region to be applied, allowing for the horizontal and vertical offsets to be applied, whilst still keeping the region safe and within the actual image size.

We finally decode the crop region from the image file. This results in a bitmap that is exactly sized to the ImageView’s height and width, rather than the much larger image file’s height and width for the normal decoder (or downsampled if required — see Glide’s Downsampler.java for details)

If your particular use case needs it, consider the use of a bitmap cache. Again Downsampler.java provides examples on how to do this. We didn’t need the additional complexity so it’s not included here.

class CroppedBitmapDecoder(val resources: Resources): ResourceDecoder<CroppedImageDecoderInput, BitmapDrawable> {

override fun handles(source: CroppedImageDecoderInput, options: Options): Boolean {
return true
}

override fun decode(source: CroppedImageDecoderInput, width: Int, height: Int, options: Options): Resource<BitmapDrawable>? {
val bitmap: Bitmap
var decoder: BitmapRegionDecoder? = null
var
inputStream: InputStream? = null

val
bitmapFactoryOptions = BitmapFactory.Options().apply {
// Decode image dimensions only, not content
inJustDecodeBounds = true
}

// Determine the image's height and width
BitmapFactory.decodeResource(resources, source.resId, bitmapFactoryOptions)
val imageHeight = bitmapFactoryOptions.outHeight
val
imageWidth = bitmapFactoryOptions.outWidth

try
{
inputStream = resources.openRawResource(source.resId)
decoder = BitmapRegionDecoder.newInstance(inputStream, false)

// Ensure the cropping and translation region doesn't exceed the image dimensions
val region = Rect(source.horizontalOffset,
source.verticalOffset,
Math.min(source.viewWidth + source.horizontalOffset, imageWidth),
Math.min(source.viewHeight + source.verticalOffset, imageHeight))

// Decode image content within the cropping region
bitmapFactoryOptions.inJustDecodeBounds = false
bitmap = decoder!!.decodeRegion(region, bitmapFactoryOptions)
} finally {
inputStream?.close()
decoder?.recycle()
}

val drawable = BitmapDrawable(resources, bitmap)
return SimpleResource<BitmapDrawable>(drawable)
}
}

Data input source class: CroppedImageDecoderInput — This is practically identical to CroppedImage but dressed up as an input source for the decoder to do its work.

class CroppedImageDecoderInput(
@RawRes @DrawableRes val resId: Int,
val viewWidth: Int,
val viewHeight: Int,
val horizontalOffset: Int = 0,
val verticalOffset: Int = 0
)

Glide module configuration class: GlideCropperGlideModule — This class tells Glide how to support the custom model CroppedImage by specifying the framework model, model loader, factory, decoder, decoder input and output (BitmapDrawable)

@GlideModule
class GlideCropperGlideModule : AppGlideModule() {

override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.prepend(CroppedImage::class.java,
CroppedImageDecoderInput::class.java,
CroppedImageModelLoaderFactory(context.resources))
.prepend(CroppedImageDecoderInput::class.java,
BitmapDrawable::class.java,
CroppedBitmapDecoder(context.resources))
}
}

With the above custom Glide framework classes, we now have enough to load cropped and translated images. However let’s create a reusable ImageView widget that can be dropped into layout XML files directly: CroppedImageView. With this widget we can also specify the horizontal and vertical offsets directly in the layout.

class CroppedImageView(context: Context, attrs: AttributeSet) : ImageView(context, attrs) {

private var horizontalOffset = 0
private var verticalOffset = 0
private var resId = 0
private var loadRequested = false

init
{
init(context, attrs)
}

private fun init(context: Context, attrs: AttributeSet) {
scaleType = ScaleType.FIT_XY
val
ats = context.theme.obtainStyledAttributes(attrs, R.styleable.CroppedImageView, 0, 0)
horizontalOffset = ats.getDimensionPixelOffset(R.styleable.CroppedImageView_horizontalOffset, 0)
verticalOffset = ats.getDimensionPixelOffset(R.styleable.CroppedImageView_verticalOffset, 0)
ats.recycle()
}

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
if (resId != 0) {
loadCroppedImage()
}
}

override fun setImageResource(resId: Int) {
this.resId = resId
loadRequested = false
if
(height != 0 && width != 0) {
loadCroppedImage()
}
}

private fun loadCroppedImage() {
if (resId == 0 || loadRequested) return
loadRequested
= true // Don't trigger multiple loads for the same resource
val model = CroppedImage(resId = resId,
viewWidth = width,
viewHeight = height,
horizontalOffset = horizontalOffset,
verticalOffset = verticalOffset)
Glide.with(context)
.load(model)
.into(this)
}

fun clear() {
// Stop any in-progress image loading
Glide.with(context).clear(this)
}
}

Notice that the actual glide code to load the cropped image is simply:

Glide.with(context)
.load(model)
.into(this)

In the layout we just use a CroppedImageView whenever we want a cropped image, and a standard ImageView when we want normal image loading:

<au.com.digio.glidecropper.widget.CroppedImageView
android:id="@+id/productImage"
android:layout_width="match_parent"
android:layout_height="150dp"
app:horizontalOffset="200dp"
app:verticalOffset="500dp"
/>

Finally, let’s flush memory and disk caches on app startup.

GlideApp.get(this@MainActivity).clearMemory()
doAsync {
GlideApp.get(this@MainActivity).clearDiskCache()
}

Conclusion

Glide is a very flexible Android image loading library that allows you to plug-in additional support for behaviour beyond what has been built in to the library. Once you get a handle on the framework’s class needs, you can customise almost every aspect of image loading.

--

--