Composite semi-transparent avatars over a solid background (#3874)

Avatars that are semi-transparent are a problem when viewing a thread,
as the line that connects different statuses in the same thread is drawn
underneath the avatar and is visible.

Fix this with a CompositeWithOpaqueBackground Glide transformation that:

1. Extracts the alpha channel from the avatar image
2. Converts the alpha to a 1bpp mask
3. Draws that mask on a new bitmap, with the appropriate background
colour
4. Draws the original bitmap on top of that

So any partially transparent areas of the original image are drawn over
a solid background colour, so anything drawn under them will not appear.
This commit is contained in:
Nik Clayton 2023-08-08 23:09:59 +02:00 committed by GitHub
parent bc310ca3fb
commit 4169dc34c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 194 additions and 13 deletions

View File

@ -52,6 +52,7 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
import com.keylesspalace.tusky.util.AttachmentHelper; import com.keylesspalace.tusky.util.AttachmentHelper;
import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.LinkHelper;
@ -67,6 +68,7 @@ import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData; import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
@ -328,14 +330,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
avatarInset.setVisibility(View.VISIBLE); avatarInset.setVisibility(View.VISIBLE);
avatarInset.setBackground(null); avatarInset.setBackground(null);
ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp, ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp,
statusDisplayOptions.animateAvatars()); statusDisplayOptions.animateAvatars(), null);
avatarRadius = avatarRadius36dp; avatarRadius = avatarRadius36dp;
} }
ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius, ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius,
statusDisplayOptions.animateAvatars()); statusDisplayOptions.animateAvatars(),
Collections.singletonList(new CompositeWithOpaqueBackground(avatar)));
} }
protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {

View File

@ -144,7 +144,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
ImageView avatarView = avatars[i]; ImageView avatarView = avatars[i];
if (i < accounts.size()) { if (i < accounts.size()) {
ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView, ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView,
avatarRadius48dp, statusDisplayOptions.animateAvatars()); avatarRadius48dp, statusDisplayOptions.animateAvatars(), null);
avatarView.setVisibility(View.VISIBLE); avatarView.setVisibility(View.VISIBLE);
} else { } else {
avatarView.setVisibility(View.GONE); avatarView.setVisibility(View.GONE);

View File

@ -0,0 +1,168 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.util
import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.graphics.Shader
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.util.Log
import android.util.TypedValue
import android.view.View
import androidx.annotation.AttrRes
import androidx.core.content.ContextCompat
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import com.bumptech.glide.util.Util
import java.nio.ByteBuffer
import java.nio.charset.Charset
import java.security.MessageDigest
/**
* Set an opaque background behind the non-transparent areas of a bitmap.
*
* Profile images may have areas that are partially transparent (i.e., alpha value >= 1 and < 255).
*
* Displaying those can be a problem if there is anything drawn under them, as it will show
* through the image.
*
* Fix this, by:
*
* - Creating a mask that matches the partially transparent areas of the image
* - Creating a new bitmap that, in the areas that match the mask, contains the same background
* drawable as the [ImageView].
* - Composite the original image over the top
*
* So the partially transparent areas on the original image are composited over the original
* background, the fully transparent areas on the original image are left transparent.
*/
class CompositeWithOpaqueBackground(val view: View) : BitmapTransformation() {
override fun equals(other: Any?): Boolean {
if (other is CompositeWithOpaqueBackground) {
return other.view == view
}
return false
}
override fun hashCode() = Util.hashCode(ID.hashCode(), view.hashCode())
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(ID_BYTES)
messageDigest.update(ByteBuffer.allocate(4).putInt(view.hashCode()).array())
}
override fun transform(
pool: BitmapPool,
toTransform: Bitmap,
outWidth: Int,
outHeight: Int
): Bitmap {
// If the input bitmap has no alpha channel then there's nothing to do
if (!toTransform.hasAlpha()) return toTransform
Log.d(TAG, "toTransform: ${toTransform.width} ${toTransform.height}")
// Get the background drawable for this view, falling back to the given attribute
val backgroundDrawable = view.getFirstNonNullBackgroundOrAttr(android.R.attr.colorBackground)
backgroundDrawable ?: return toTransform
// Convert the background to a bitmap.
val backgroundBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888)
when (backgroundDrawable) {
is ColorDrawable -> backgroundBitmap.eraseColor(backgroundDrawable.color)
else -> {
val backgroundCanvas = Canvas(backgroundBitmap)
backgroundDrawable.setBounds(0, 0, outWidth, outHeight)
backgroundDrawable.draw(backgroundCanvas)
}
}
// Convert the alphaBitmap (where the alpha channel has 8bpp) to a mask of 1bpp
// TODO: toTransform.extractAlpha(paint, ...) could be used here, but I can't find any
// useful documentation covering paints and mask filters.
val maskBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ALPHA_8).apply {
val canvas = Canvas(this)
canvas.drawBitmap(toTransform, 0f, 0f, EXTRACT_MASK_PAINT)
}
val shader = BitmapShader(backgroundBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
val paintShader = Paint()
paintShader.isAntiAlias = true
paintShader.shader = shader
paintShader.style = Paint.Style.FILL_AND_STROKE
// Write the background to a new bitmap, masked to just the non-transparent areas of the
// original image
val dest = pool.get(outWidth, outHeight, toTransform.config)
val canvas = Canvas(dest)
canvas.drawBitmap(maskBitmap, 0f, 0f, paintShader)
// Finally, write the original bitmap over the top
canvas.drawBitmap(toTransform, 0f, 0f, null)
// Clean up intermediate bitmaps
pool.put(maskBitmap)
pool.put(backgroundBitmap)
return dest
}
companion object {
@Suppress("unused")
private const val TAG = "CompositeWithOpaqueBackground"
private val ID = CompositeWithOpaqueBackground::class.qualifiedName!!
private val ID_BYTES = ID.toByteArray(Charset.forName("UTF-8"))
/** Paint with a color filter that converts 8bpp alpha images to a 1bpp mask */
private val EXTRACT_MASK_PAINT = Paint().apply {
colorFilter = ColorMatrixColorFilter(
ColorMatrix(
floatArrayOf(
0f, 0f, 0f, 0f, 0f,
0f, 0f, 0f, 0f, 0f,
0f, 0f, 0f, 0f, 0f,
0f, 0f, 0f, 255f, 0f
)
)
)
isAntiAlias = false
}
/**
* @param attr attribute reference for the default drawable if no background is set on
* this view or any of its ancestors.
* @return The first non-null background drawable from this view, or its ancestors,
* falling back to the attribute resource given by `attr` if none of the views have a
* background.
*/
fun View.getFirstNonNullBackgroundOrAttr(@AttrRes attr: Int): Drawable? =
background ?: (parent as? View)?.getFirstNonNullBackgroundOrAttr(attr) ?: run {
val v = TypedValue()
context.theme.resolveAttribute(attr, v, true)
// TODO: On API 29 can use v.isColorType here
if (v.type >= TypedValue.TYPE_FIRST_COLOR_INT && v.type <= TypedValue.TYPE_LAST_COLOR_INT) {
ColorDrawable(v.data)
} else {
ContextCompat.getDrawable(context, v.resourceId)
}
}
}
}

View File

@ -3,39 +3,50 @@
package com.keylesspalace.tusky.util package com.keylesspalace.tusky.util
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import android.widget.ImageView import android.widget.ImageView
import androidx.annotation.Px import androidx.annotation.Px
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.MultiTransformation
import com.bumptech.glide.load.Transformation
import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.CenterCrop
import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
private val centerCropTransformation = CenterCrop() private val centerCropTransformation = CenterCrop()
fun loadAvatar(url: String?, imageView: ImageView, @Px radius: Int, animate: Boolean) { fun loadAvatar(
url: String?,
imageView: ImageView,
@Px radius: Int,
animate: Boolean,
transforms: List<Transformation<Bitmap>>? = null
) {
if (url.isNullOrBlank()) { if (url.isNullOrBlank()) {
Glide.with(imageView) Glide.with(imageView)
.load(R.drawable.avatar_default) .load(R.drawable.avatar_default)
.into(imageView) .into(imageView)
} else { } else {
val multiTransformation = MultiTransformation(
buildList {
transforms?.let { this.addAll(it) }
add(centerCropTransformation)
add(RoundedCorners(radius))
}
)
if (animate) { if (animate) {
Glide.with(imageView) Glide.with(imageView)
.load(url) .load(url)
.transform( .transform(multiTransformation)
centerCropTransformation,
RoundedCorners(radius)
)
.placeholder(R.drawable.avatar_default) .placeholder(R.drawable.avatar_default)
.into(imageView) .into(imageView)
} else { } else {
Glide.with(imageView) Glide.with(imageView)
.asBitmap() .asBitmap()
.load(url) .load(url)
.transform( .transform(multiTransformation)
centerCropTransformation,
RoundedCorners(radius)
)
.placeholder(R.drawable.avatar_default) .placeholder(R.drawable.avatar_default)
.into(imageView) .into(imageView)
} }