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:
parent
bc310ca3fb
commit
4169dc34c0
|
@ -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) {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue