-
-
Notifications
You must be signed in to change notification settings - Fork 389
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
Nik Clayton
authored
Aug 8, 2023
1 parent
bc310ca
commit 4169dc3
Showing
4 changed files
with
194 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
168 changes: 168 additions & 0 deletions
168
app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters