• Messengers-like ImageView
  • By Michael Spitsin
  • The Nuggets translation Project
  • Permanent link to this article: github.com/xitu/gold-m…
  • Translator: Hoarfroster
  • Proofreader: HumanBeing, keepmovingljzy

In the last article, we discussed uploading an animation when sending a picture message and how to build this animation. Today I decided to write an article about the display of pictures. To start with, how should we display images in message history?

Use ImageView, of course! Well, that’s the end of this article!

Hey, don’t go! While we can use ImageView to simply display images, it’s not as easy as we might think to display images in Messenger apps. It’s not that difficult per se, but all we need is not just an ImageView, but some small calculations to see if the size fits the content we’re presenting to the user.

measurement

First we need to know what we should do. The core of our solution is that we consider the size limits of the containers that display images based on the predefined sizes (width and height) of some images or giFs, or any other displayable media (for example, we might need to shrink a 1000×1000 image to fit a 100×100 container), And you should keep the aspect ratio for the most part.

That is, the basic steps can be divided into two parts:

  1. Define the optimal size based on the container size and provide it to the program. If the image is too small, we need to adjust it until the shortest side is equal to the shortest side of the container.
  2. Give it and the container some constraints (such as usemaxSizeControl the size). And if possible, we should use the aspect ratio to calculate the final size (we will discuss other possibilities later).

Define the size

Let’s start with a simple definition of the Size class. It will contain the width and height of the image, and add methods to it that will help us calculate:

internal class Size(
    var width: Int.var height: Int
) {
    val area: Int get() = width * height
    val ratio: Float get() = height.toFloat() / width

    operator fun contains(other: Size) = width >= other.width && height >= other.height

    fun update(new: Size) {
        width = new.width
        height = new.height
    }

    // We don't want to change mutable class to data class
    fun copy(width: Int = this.width, height: Int = this.height) = Size(width, height)
}
Copy the code

Here are a few caveats:

  1. We define this class as mutable because it will be used within the view, and we want to optimize instance creation — since our program works in a thread, we don’t want or need to waste a lot of resources creating it.
  2. We can actually takeSizeThe class itself is treated as a data class rather than a custom onecopyFunction, but I don’t want to associate variability with data classes, because data classes are supposed to be immutable.

Now that we have defined the Size class, we can create an ImageSizeMeasurer class that defines, adjusts, and measures the Size.

Set the desired size

First we will set the size and minimum size required for the image. In this method, we check to see if the required size is less than the minimum, and if so, adjust it in turn:

internal class ImageSizeMeasurer {
    private var minSize: Size = Size(0.0)

    private var desiredSize = Size(0.0)
    val desired get() = desiredSize.copy()

    // = (height/width)
    var fixRatio: Float = 1f
        private set

    // ...

    fun setDesiredSize(desired: Size, min: Size) {
        minSize = min.copy()

        desiredSize = desired.copy()
        fixRatio = desired.ratio

        adjustDesiredHeight()
        adjustDesiredWidth()
    }

    private fun adjustDesiredHeight(a) {
        if (desiredSize.height < minSize.height) {
            desiredSize.height = minSize.height
            desiredSize.width = (minSize.height / fixRatio).toInt()
        }
    }

    private fun adjustDesiredWidth(a) {
        if (desiredSize.width < minSize.width) {
            desiredSize.width = minSize.width
            desiredSize.height = (minSize.width * fixRatio).toInt()
        }
    }
}
Copy the code

Here we use the copy method to prevent data from being modified by client operations. The copy method allows this data to be secretly shared between different places (so we’re not shocked when a field is being changed).

The key point here is that after setting the size and scale, we need to adjust them. AdjustDesiredHeight and adjustDesiredWidth are called in adjustDesiredWidth without any intelligent checking. Because the first method increases the minimum height in desiredSize to minSize if the height is less than width, The second function increases the minimum desiredSize width to minSize if width is less than height.

The constraints are measured separately

We have adjusted the minimum size of the desired size, now it is time to measure the maximum size of the actual size. This method itself is not difficult, we just need to remember that all updates should not change the aspect ratio, unless lowering the height and width of the image results in one of them being smaller than minSize.

For example, for very narrow images.

  • Either the width meets the maximum but the height is too small
  • Or the height is the minimum but the width is too large
  • Either set the width to the maximum and the height to the minimum, but this destroys the aspect ratio.

In that case, the last option is the most appropriate choice, because we don’t want the image size to exceed the constraint size, and we don’t want the image to be too narrow, because too small an image might make it difficult to see or interact with the image’s content. Here we can use scaleType = imageCrop: it helps you display images correctly without breaking their aspect ratio.

internal class ImageSizeMeasurer {
    private var minSize: Size = Size(0.0)

    var desiredSize = Size(0.0)
        get() = field.copy()
        private set

    // Factor: 'height' : 'width'
    var fixRatio: Float = 1f
        private set

    // ...

    fun measure(max: Size.out: Size) {
        when {
            desiredSize in max -> out.update(desiredSize)
            fixRatio < max.ratio -> {
                out.width = max.width
                out.height = max((max.width * desiredSize.height) / desiredSize.width, minSize.height)
            }
            fixRatio > max.ratio -> {
                out.width = max((max.height * desiredSize.width) / desiredSize.height, minSize.width)
                out.height = max.height
            }
            else -> out.update(max)
        }
    }
    
    / /... Or set the desired size here
}
Copy the code

Let’s take a quick look at the measure method.

  • When the required size fits the maximum size, all is well.setDesiredSizeAfter determining the size, we will ensure that the size is not less than the minimum size. Now, we just need to make sure it’s not bigger than the maximum size. Therefore, we will return itself (whenThe first conditional in the code block)
  • If the above prediction is wrong, then either the width is greater thanmax.widthOr the height is greater thanmax.heightOr both. In this case, the aspect ratio of the picture will be the same as the aspect ratio of the maximum size, and we can output the maximum size because it will shrink the result to the desired size. (whenIn the code blockelseStatement)
  • In the other case, we just need to compare the aspect ratio. For example,widthDesired size is greater than maximum size. But the aspect ratio of the desired size is also greater than the aspect ratio of the maximum size. This means that when we reduce the desired size (and therefore the current sizewidthWill be equal to the maximum size), desired sizeheightStill larger than the maximum sizeheight.

Therefore, when the aspect ratio of the desired size is less than the aspect ratio of the maximum size, we simply update width to max.width and the height will be updated accordingly. But if it is less than minsize. height, we break the resulting aspect ratio and set out.height to minsize. height.

  • Similarly, if the aspect ratio of the desired size is greater than the aspect ratio of the maximum size, we just need to change theheightUpdated tomax.heightAnd the width is updated accordingly. But if it’s less thanminSize.widthWe will break the resulting aspect ratio and willout.widthSet tominSize.width.

There is a bit of magic in all the calculations to make everything more natural and beautiful

Now, we have everything we need to measure:

private val measurer: ImageSizeMeasurer
private val measureResult = Size(0.0)
private val maxSize = Size(0.0)

// ...

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED) {
        setMeasuredDimension(measurer.desired.width, measurer.desired.height)
    } else {
        measure(widthMeasureSpec, heightMeasureSpec)
    }
}

private fun measure(widthSpec: Int, heightSpec: Int) {
    maxSize.width = // You can define the maximum size here: you can use two input sizes, or you can use hard-coded ones
    maxSize.height = // aspect ratio and use only one of the input sizes, whichever you choose :)
    measurer.measure(maxSize, measureResult)
    setMeasuredDimension(measureResult.width, measureResult.height)
}
Copy the code

Everything here is simple enough. If we have an unspecified size, we tell the parent control of the view what size it should ideally be. In the other case (most of the time), we need to provide width and height to set maxSize and pass them into our Measurer.

Now let’s look at the results:

Everything looks great, but the smaller images seem too small. We want to make them bigger than they are now. You might say, “Go up to the minimum size.” We could certainly do that, but in that case there would be no difference in the size of the display between the smaller and smaller images, because they would use the same minimum size definition.

Besides, we can do some magic!

The main purpose of magic is to make the small graph bigger by adding some magic number or formula, and to maintain the size difference between the small graph and the smaller graph 🙂

fun measure(max: Size.out: Size) {
    val desired = desiredSize.copy()
    magicallyStretchTooSmallSize(max, desired)

    // ... 
}

private fun magicallyStretchTooSmallSize(max: ChatImageView.Size, desired: ChatImageView.Size) {
    if (desired in max) {
        // If the image is smaller than the maximum size, we can move the image as close to the edge of the container as possible
        // This is intentional to tell the user that he is sending a small image,
        // The adjusted image will not become too small, we just need to use magic adjustment to make the image more beautiful.
        val adjustedArea = desired.area + (max.area - desired.area) / 3f
        val outW = sqrt(adjustedArea / fixRatio)
        desired.height = (outW * fixRatio).toInt()
        desired.width = outW.toInt()
    }
}
Copy the code

The idea is simple: increase the desired area to 1/3 of the difference between the maximum and desired area, and then calculate the new width and height using the new area and aspect ratio.

This is a comparison.

I liked the new result better: we were able to have larger images and see them more easily. But at the same time, we can understand that the sizes of the images are different, some are bigger and some are smaller.

What if I only know the scale and want to get the size

Let’s discuss further whether we can make further improvements. Sometimes we don’t have the actual size of the image we want to show, and only get the thumbnail size. In this case, we can’t use the thumbnail size as the desired size because these are much smaller, but we can calculate whether the scale is large, small or exactly the same, and then use the ImageSizeMeasurer object, with only a fixed aspect ratio, Try to figure out the size you want and make it fit as many constraints as possible.

So, first, we add a new attribute to the Size class:

val isSpecified: Boolean get() = width > 0 && height > 0
Copy the code

Next, we need to add methods to set the desired ratio rather than the desired size:

fun setDesiredRatio(ratio: Float, min: Size) {
    minSize = min.copy()
    desiredSize = Size(0, 0)
    fixRatio = ratio
}
Copy the code

We then need to update the measure method by adding other resizations to the desired size:

fun measure(max: ChatImageView.Size.out: ChatImageView.Size) {
    val desired = desiredSize.copy()
    fixUnspecifiedDesiredSize(max, desired)
    magicallyStretchTooSmallSize(max, desired)
    
    // ...
}

// The desired size is not specified, but the aspect ratio is specified, so first maximize the image stretch.
private fun fixUnspecifiedDesiredSize(max: ChatImageView.Size, desired: ChatImageView.Size) {
    if(! desired.isSpecified) {if (fixRatio > max.ratio) {
            desired.width = max.width
            desired.height = (max.width * fixRatio).toInt()
        } else {
            desired.width = (max.height / fixRatio).toInt()
            desired.height = max.height
        }
    }
}
Copy the code

Finally, let’s update the onMeasure method:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    if (measurer.desired.isSpecified) {
        if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED) {
            setMeasuredDimension(measurer.desired.width, measurer.desired.height)
        } else {
            measure(widthMeasureSpec)
        }
    } else if (measurer.fixRatio > 0) {
        if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        } else {
            measure(widthMeasureSpec)
        }
    } else {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    }
}

private fun measure(widthSpec: Int) {
    maxSize.width = // We can use both specifications or have some predefined ones
    maxSize.height = // use both width and height or just one of them to define the maximum size;)
    measurer.measure(maxSize, measureResult)
    setMeasuredDimension(measureResult.width, measureResult.height)
}
Copy the code

Let’s talk about views

So far so good. We have a wonderful picture sizing tool! We even have a rough idea of how to integrate it with the view, but we still have no idea how to represent it.

Let’s start by describing what we want. In fact, specifying minWidth and minHeight is not difficult. These attributes are part of the XML, as are maxWidth and maxHeight. But I don’t want to hardcode any particular size here. Instead, I want to rely more on the device screen. This means that it is best to specify these maximum constraints as percentages. Now that we have the ConstraintLayout control, it should not be difficult to specify a maximum width like this (say 70% of the screen width)… But what about the height?

Well, you can specify any constraint ratio, but that’s just my idea. For some reason, I decided to determine the height based on the width, multiplied by the scaling factor. So, assuming our factor value is 1, that’s a square. That is, simply specify width (and scale) and the program calculates the corresponding height.

As you can see, the approach is extremely simple, but also extremely dependent on screen size, rather than the various definitions in Dimens.xml that depend on different factors of the device, although the latter solution is android-based:

open class FixRatioImageView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
    var factor = DEFAULT_FACTOR
        set(value) {
            if(field ! = value) { field = value requestLayout() } }init{ attrs? .parseAttrs(context, R.styleable.FixRatioImageView) { factor = getFloat(R.styleable.FixRatioImageView_factor, DEFAULT_FACTOR) } }override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val width = View.getDefaultSize(suggestedMinimumWidth, widthMeasureSpec)
        setMeasuredDimension(width, ceil(width * factor).toInt())
    }

    companion object {
        private const val DEFAULT_FACTOR = .6f}}Copy the code

Merge all code!

Let’s look at the final code:

class ChatImageView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : FixRatioImageView(context, attrs, defStyleAttr) {

    private val measurer = ImageSizeMeasurer()
    private val measureResult = Size(0.0)
    private val maxSize = Size(0.0)

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        if (measurer.desired.isSpecified) {
            if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED) {
                setMeasuredDimension(measurer.desired.width, measurer.desired.height)
            } else {
                measure(widthMeasureSpec)
            }
        } else if (measurer.fixRatio > 0) {
            if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED) {
                super.onMeasure(widthMeasureSpec, heightMeasureSpec)
            } else {
                measure(widthMeasureSpec)
            }
        } else {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        }
    }

    private fun measure(widthSpec: Int) {
        maxSize.width = MeasureSpec.getSize(widthSpec)
        maxSize.height = (maxSize.width * factor).toInt()
        measurer.measure(maxSize, measureResult)
        setMeasuredDimension(measureResult.width, measureResult.height)
    }

    fun setDesiredSize(width: Int, height: Int) {
        measurer.setDesiredSize(Size(width, height), Size(minimumWidth, minimumHeight))
        invalidate()
        requestLayout()
    }

    fun setDesiredRatio(ratio: Float) {
        measurer.setDesiredRatio(ratio, Size(minimumWidth, minimumHeight))
        invalidate()
        requestLayout()
    }

    internal class ImageSizeMeasurer {
        private var minSize: Size = Size(0.0)

        private var desiredSize = Size(0.0)
        val desired get() = desiredSize.copy()

        // Scale factor height: width
        var fixRatio: Float = 1f
            private set

        init { reset() }

        // We rely on the MATCH_PARENT setting on the client to get the width
        // Do not create a new Size
        fun measure(max: Size.out: Size) {
            val desired = desiredSize.copy()
            fixUnspecifiedDesiredSize(max, desired)
            magicallyStretchTooSmallSize(max, desired)

            when {
                desired in max -> out.update(desired)
                fixRatio < max.ratio -> {
                    out.width = max.width
                    out.height = max((max.width * desired.height) / desired.width, minSize.height)
                }
                fixRatio > max.ratio -> {
                    out.width = max((max.height * desired.width) / desired.height, minSize.width)
                    out.height = max.height
                }
                else -> out.update(max)
            }
        }

        // If the desired size is not specified, but the scaling factor is specified, extend the image as much as possible first
        private fun fixUnspecifiedDesiredSize(max: Size, desired: Size) {
            if(! desired.isSpecified) {if (fixRatio > max.ratio) {
                    desired.width = max.width
                    desired.height = (max.width * fixRatio).toInt()
                } else {
                    desired.width = (max.height / fixRatio).toInt()
                    desired.height = max.height
                }
            }
        }

        @Suppress("MagicNumber")
        private fun magicallyStretchTooSmallSize(max: Size, desired: Size) {
            if (desired in max) {
                // If the image is smaller than the upper bound, we stretch the image a little extra to the upper bound
                // This is intentional, because if we are sending small images, the images should not be too small.
                // therefore, it is just some kind of adjustment magic to make the image look more beautiful;)
                val adjustedArea = desired.area + (max.area - desired.area) / 3f
                val outW = sqrt(adjustedArea / fixRatio)
                desired.height = (outW * fixRatio).toInt()
                desired.width = outW.toInt()
            }
        }

        fun setDesiredRatio(ratio: Float, min: Size) {
            reset()
            minSize = min.copy()
            fixRatio = ratio
        }

        fun setDesiredSize(desired: Size, min: Size) {
            minSize = min.copy()

            if(! desired.isSpecified) { reset() }else {
                desiredSize = desired.copy()
                fixRatio = desired.ratio

                adjustDesiredHeight()
                adjustDesiredWidth()
            }
        }

        private fun adjustDesiredHeight(a) {
            if (desiredSize.height < minSize.height) {
                desiredSize.height = minSize.height
                desiredSize.width = (minSize.height / fixRatio).toInt()
            }
        }

        private fun adjustDesiredWidth(a) {
            if (desiredSize.width < minSize.width) {
                desiredSize.width = minSize.width
                desiredSize.height = (minSize.width * fixRatio).toInt()
            }
        }

        private fun reset(a) {
            desiredSize = Size(0.0)
            fixRatio = 1f}}internal class Size(
        var width: Int.var height: Int
    ) {
        val area: Int get() = width * height
        val ratio: Float get() = height.toFloat() / width
        val isSpecified: Boolean get() = width > 0 && height > 0

        operator fun contains(other: Size) = width >= other.width && height >= other.height

        fun update(new: Size) {
            width = new.width
            height = new.height
        }

        // Do not want mutable classes to be data classes
        fun copy(width: Int = this.width, height: Int = this.height) = Size(width, height)
    }
}
Copy the code

The perfect running result of our efforts:

Afterword.

If you liked this post, don’t forget to like it or support us with a triple click. If you have any questions, please leave them in the comments so we can discuss them together! Happy programming!

If you find any mistakes in your translation or other areas that need to be improved, you are welcome to the Nuggets Translation Program to revise and PR your translation, and you can also get the corresponding reward points. The permanent link to this article at the beginning of this article is the MarkDown link to this article on GitHub.


The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. The content covers Android, iOS, front-end, back-end, blockchain, products, design, artificial intelligence and other fields. If you want to see more high-quality translation, please continue to pay attention to the Translation plan of Digging Gold, the official Weibo, Zhihu column.