Skip to content

Commit

Permalink
Fix stretchy chat colors on Android 12.
Browse files Browse the repository at this point in the history
  • Loading branch information
alex-signal authored and cody-signal committed Sep 28, 2021
1 parent e637f15 commit bad382e
Show file tree
Hide file tree
Showing 10 changed files with 173 additions and 90 deletions.
Expand Up @@ -100,6 +100,10 @@ public void setBounds(int left, int top, int right, int bottom) {
fillPaint.setShader(new LinearGradient(fillRect.left, fillRect.top, fillRect.right, fillRect.bottom, colors, positions, Shader.TileMode.CLAMP));
}

public @Nullable Shader getShader() {
return fillPaint.getShader();
}

private static Point cornerPrime(@NonNull Point origin, @NonNull Point corner, float degrees) {
return new Point(xPrime(origin, corner, Math.toRadians(degrees)), yPrime(origin, corner, Math.toRadians(degrees)));
}
Expand Down
Expand Up @@ -88,7 +88,7 @@
import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener;
import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.colors.ColorizerView;
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemAnimator;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
Expand Down Expand Up @@ -218,7 +218,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
private OnScrollListener conversationScrollListener;
private int pulsePosition = -1;
private View toolbarShadow;
private ColorizerView colorizerView;
private Stopwatch startupStopwatch;

private GiphyMp4ProjectionRecycler giphyMp4ProjectionRecycler;
Expand Down Expand Up @@ -256,10 +255,8 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
scrollToMentionButton = view.findViewById(R.id.scroll_to_mention);
scrollDateHeader = view.findViewById(R.id.scroll_date_header);
toolbarShadow = requireActivity().findViewById(R.id.conversation_toolbar_shadow);
colorizerView = view.findViewById(R.id.conversation_colorizer_view);

ConversationIntents.Args args = ConversationIntents.Args.from(requireActivity().getIntent());
colorizerView.setBackground(args.getChatColors().getChatBubbleMask());

final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true);
final MultiselectItemAnimator multiselectItemAnimator = new MultiselectItemAnimator(() -> {
Expand Down Expand Up @@ -351,10 +348,10 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,

updateToolbarDependentMargins();

colorizer = new Colorizer(colorizerView);
colorizer.attachToRecyclerView(list);
colorizer = new Colorizer();
RecyclerViewColorizer recyclerViewColorizer = new RecyclerViewColorizer(list);

conversationViewModel.getChatColors().observe(getViewLifecycleOwner(), chatColors -> colorizer.onChatColorsChanged(chatColors));
conversationViewModel.getChatColors().observe(getViewLifecycleOwner(), recyclerViewColorizer::setChatColors);
conversationViewModel.getNameColorsMap().observe(getViewLifecycleOwner(), nameColorsMap -> {
colorizer.onNameColorsChanged(nameColorsMap);

Expand Down Expand Up @@ -412,12 +409,10 @@ public void onChildViewDetachedFromWindow(@NonNull View view) {
private void setListVerticalTranslation() {
if (list.canScrollVertically(1) || list.canScrollVertically(-1) || list.getChildCount() == 0) {
list.setTranslationY(0);
colorizerView.setTranslationY(0);
list.setOverScrollMode(RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS);
} else {
int chTop = list.getChildAt(list.getChildCount() - 1).getTop();
list.setTranslationY(Math.min(0, -chTop));
colorizerView.setTranslationY(Math.min(0, -chTop));
list.setOverScrollMode(RecyclerView.OVER_SCROLL_NEVER);
}

Expand Down Expand Up @@ -539,10 +534,6 @@ private void onViewHolderPositionTranslated(@NonNull RecyclerView recyclerView,
if (viewHolder instanceof GiphyMp4Playable) {
giphyMp4ProjectionRecycler.updateVideoDisplayPositionAndSize(recyclerView, (GiphyMp4Playable) viewHolder);
}

if (colorizer != null) {
colorizer.applyClipPathsToMaskedGradient(recyclerView);
}
}

private int getStartPosition() {
Expand Down
Expand Up @@ -1718,7 +1718,8 @@ public boolean canPlayContent() {
if (messageRecord.isOutgoing() &&
!hasNoBubble(messageRecord) &&
!messageRecord.isRemoteDelete() &&
bodyBubbleCorners != null)
bodyBubbleCorners != null &&
bodyBubble.getProjections().isEmpty())
{
projections.add(Projection.relativeToViewRoot(bodyBubble, bodyBubbleCorners).translateX(bodyBubble.getTranslationX()));
}
Expand Down
Expand Up @@ -5,6 +5,7 @@ import android.graphics.ColorFilter
import android.graphics.Path
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Shader
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.ShapeDrawable
Expand Down Expand Up @@ -53,6 +54,18 @@ class ChatColors private constructor(
}
}

fun asShader(left: Int, top: Int, right: Int, bottom: Int): Shader? {
return linearGradient?.let {
RotatableGradientDrawable(
linearGradient.degrees,
linearGradient.colors,
linearGradient.positions
).apply {
setBounds(left, top, right, bottom)
}
}?.shader
}

/**
* Returns the ColorFilter to apply to a conversation bubble or other relevant piece of UI.
*/
Expand Down
Expand Up @@ -2,16 +2,11 @@ package org.thoughtcrime.securesms.conversation.colors

import android.content.Context
import android.graphics.Color
import android.os.Build
import android.view.View
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.Projection

/**
* Helper class for all things ChatColors.
Expand All @@ -20,7 +15,7 @@ import org.thoughtcrime.securesms.util.Projection
* - Gives easy access to different bubble colors
* - Watches and responds to RecyclerView scroll and layout changes to update a ColorizerView
*/
class Colorizer(private val colorizerView: ColorizerView) : RecyclerView.OnScrollListener(), View.OnLayoutChangeListener {
class Colorizer {

private var colorsHaveBeenSet = false
private val groupSenderColors: MutableMap<RecipientId, NameColor> = mutableMapOf()
Expand All @@ -43,55 +38,12 @@ class Colorizer(private val colorizerView: ColorizerView) : RecyclerView.OnScrol
@ColorInt
fun getIncomingGroupSenderColor(context: Context, recipient: Recipient): Int = groupSenderColors[recipient.id]?.getColor(context) ?: getDefaultColor(context, recipient.id)

fun attachToRecyclerView(recyclerView: RecyclerView) {
recyclerView.addOnScrollListener(this)
recyclerView.addOnLayoutChangeListener(this)
}

fun onNameColorsChanged(nameColorMap: Map<RecipientId, NameColor>) {
groupSenderColors.clear()
groupSenderColors.putAll(nameColorMap)
colorsHaveBeenSet = true
}

fun onChatColorsChanged(chatColors: ChatColors) {
colorizerView.background = chatColors.chatBubbleMask
}

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
applyClipPathsToMaskedGradient(recyclerView)
}

override fun onLayoutChange(v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
applyClipPathsToMaskedGradient(v as RecyclerView)
}

fun applyClipPathsToMaskedGradient(recyclerView: RecyclerView) {
if (Build.VERSION.SDK_INT < 21) {
return
}

val layoutManager = recyclerView.layoutManager as LinearLayoutManager

val firstVisibleItemPosition: Int = layoutManager.findFirstVisibleItemPosition()
val lastVisibleItemPosition: Int = layoutManager.findLastVisibleItemPosition()

val projections: List<Projection> = (firstVisibleItemPosition..lastVisibleItemPosition)
.mapNotNull { recyclerView.findViewHolderForAdapterPosition(it) as? Colorizable }
.map {
it.colorizerProjections
.map { p -> Projection.translateFromRootToDescendantCoords(p, colorizerView) }
}
.flatten()

if (projections.isNotEmpty()) {
colorizerView.visibility = View.VISIBLE
colorizerView.setProjections(projections)
} else {
colorizerView.visibility = View.GONE
}
}

@ColorInt
private fun getDefaultColor(context: Context, recipientId: RecipientId): Int {
return if (colorsHaveBeenSet) {
Expand Down
@@ -0,0 +1,142 @@
package org.thoughtcrime.securesms.conversation.colors

import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.Rect
import android.view.View
import android.widget.EdgeEffect
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView

/**
* Draws the ChatColors color or gradient following this procedure:
*
* 1. Have the RecyclerView's ItemDecoration#onDraw method, fill the bounds of the RecyclerView with the background color or drawable
* 2. Have each child item draw the bubble shape with the "clear" blend mode to "hole punch" a region within the background already drawn by the RecyclerView
* 3. In the RecyclerView's ItemDecoration#onDrawOver method, draw the gradient with the full bounds of the RecyclerView using the DST_OVER blend mode. This will draw the gradient "underneath" the background rendered in step 1 however will show portions of the gradient in the areas "cleared" by the rendering in step 2
*/
class RecyclerViewColorizer(private val recyclerView: RecyclerView) {

private var topEdgeEffect: EdgeEffect? = null
private var bottomEdgeEffect: EdgeEffect? = null

private fun getLayoutManager(): LinearLayoutManager = recyclerView.layoutManager as LinearLayoutManager

private var useLayer = false

private val noLayerXfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER)
private val layerXfermode = PorterDuffXfermode(PorterDuff.Mode.XOR)

private var chatColors: ChatColors? = null

fun setChatColors(chatColors: ChatColors) {
this.chatColors = chatColors
recyclerView.invalidateItemDecorations()
}

private val edgeEffectFactory = object : RecyclerView.EdgeEffectFactory() {
override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
val edgeEffect = super.createEdgeEffect(view, direction)
when (direction) {
DIRECTION_TOP -> topEdgeEffect = edgeEffect
DIRECTION_BOTTOM -> bottomEdgeEffect = edgeEffect
DIRECTION_LEFT -> Unit
DIRECTION_RIGHT -> Unit
}

return edgeEffect
}
}

private val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)

val firstItemPos = getLayoutManager().findFirstVisibleItemPosition()
val lastItemPos = getLayoutManager().findLastVisibleItemPosition()
val itemCount = getLayoutManager().itemCount
val firstVisible = firstItemPos == 0 && itemCount >= 1
val lastVisible = lastItemPos == itemCount - 1 && itemCount >= 1

if (firstVisible || lastVisible || isOverscrolled()) {
useLayer = true
recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
} else {
useLayer = false
recyclerView.setLayerType(View.LAYER_TYPE_NONE, null)
}
}
}

private val itemDecoration = object : RecyclerView.ItemDecoration() {
private val holePunchPaint = Paint().apply {
xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
color = Color.BLACK
}

private val shaderPaint = Paint()
private val colorPaint = Paint()

override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
outRect.setEmpty()
}

override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(c, parent, state)

val colors = chatColors ?: return

if (useLayer) {
c.drawColor(Color.WHITE)
}

for (i in 0 until parent.childCount) {
val child = parent.getChildAt(i)
if (child != null && child is Colorizable) {
child.colorizerProjections.forEach {
c.drawPath(it.path, holePunchPaint)
}
}
}

drawShaderMask(c, parent, colors)
}

private fun drawShaderMask(canvas: Canvas, parent: RecyclerView, chatColors: ChatColors) {
if (useLayer) {
shaderPaint.xfermode = layerXfermode
colorPaint.xfermode = layerXfermode
} else {
shaderPaint.xfermode = noLayerXfermode
colorPaint.xfermode = noLayerXfermode
}

val shader = chatColors.asShader(0, 0, parent.width, parent.height)
shaderPaint.shader = shader
colorPaint.color = chatColors.asSingleColor()

canvas.drawRect(
0f,
0f,
parent.width.toFloat(),
parent.height.toFloat(),
if (shader == null) colorPaint else shaderPaint
)
}
}

init {
recyclerView.edgeEffectFactory = edgeEffectFactory
recyclerView.addOnScrollListener(scrollListener)
recyclerView.addItemDecoration(itemDecoration)
}

private fun isOverscrolled(): Boolean {
val topFinished = topEdgeEffect?.isFinished ?: true
val bottomFinished = bottomEdgeEffect?.isFinished ?: true
return !topFinished || !bottomFinished
}
}
Expand Up @@ -101,7 +101,7 @@ class ChatColorPreviewView @JvmOverloads constructor(
wallpaper = findViewById(R.id.wallpaper)
wallpaperDim = findViewById(R.id.wallpaper_dim)
colorizerView = findViewById(R.id.colorizer)
colorizer = Colorizer(colorizerView)
colorizer = Colorizer()
} finally {
typedArray?.recycle()
}
Expand Down

0 comments on commit bad382e

Please sign in to comment.