From bad382e2f31ce9e6d59383c9d6232341049d8979 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 27 Sep 2021 15:04:44 -0300 Subject: [PATCH] Fix stretchy chat colors on Android 12. --- .../components/RotatableGradientDrawable.java | 4 + .../conversation/ConversationFragment.java | 17 +-- .../conversation/ConversationItem.java | 3 +- .../conversation/colors/ChatColors.kt | 13 ++ .../conversation/colors/Colorizer.kt | 50 +----- .../colors/RecyclerViewColorizer.kt | 142 ++++++++++++++++++ .../colors/ui/ChatColorPreviewView.kt | 2 +- .../MessageDetailsActivity.java | 13 +- .../main/res/layout/conversation_fragment.xml | 10 -- .../res/layout/message_details_activity.xml | 9 -- 10 files changed, 173 insertions(+), 90 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/colors/RecyclerViewColorizer.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RotatableGradientDrawable.java b/app/src/main/java/org/thoughtcrime/securesms/components/RotatableGradientDrawable.java index 60fb3e2648..d55327a561 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/RotatableGradientDrawable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/RotatableGradientDrawable.java @@ -100,6 +100,10 @@ public final class RotatableGradientDrawable extends Drawable { 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))); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index e504e57573..a9aebf5d1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -88,7 +88,7 @@ import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity; 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; @@ -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; @@ -256,10 +255,8 @@ public class ConversationFragment extends LoggingFragment implements Multiselect 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(() -> { @@ -351,10 +348,10 @@ public class ConversationFragment extends LoggingFragment implements Multiselect 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); @@ -412,12 +409,10 @@ public class ConversationFragment extends LoggingFragment implements Multiselect 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); } @@ -539,10 +534,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect if (viewHolder instanceof GiphyMp4Playable) { giphyMp4ProjectionRecycler.updateVideoDisplayPositionAndSize(recyclerView, (GiphyMp4Playable) viewHolder); } - - if (colorizer != null) { - colorizer.applyClipPathsToMaskedGradient(recyclerView); - } } private int getStartPosition() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 37fd206fb4..f7ccac39e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -1718,7 +1718,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo if (messageRecord.isOutgoing() && !hasNoBubble(messageRecord) && !messageRecord.isRemoteDelete() && - bodyBubbleCorners != null) + bodyBubbleCorners != null && + bodyBubble.getProjections().isEmpty()) { projections.add(Projection.relativeToViewRoot(bodyBubble, bodyBubbleCorners).translateX(bodyBubble.getTranslationX())); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColors.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColors.kt index 4bcb2d689e..c289bcfbf2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColors.kt @@ -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 @@ -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. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt index b37683e67f..92bf6063f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt @@ -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. @@ -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 = mutableMapOf() @@ -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) { 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 = (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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/RecyclerViewColorizer.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/RecyclerViewColorizer.kt new file mode 100644 index 0000000000..81ea87af98 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/RecyclerViewColorizer.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorPreviewView.kt index 354dff4b93..b1dc501854 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorPreviewView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorPreviewView.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java index 2f9a3b045c..910724a6fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java @@ -8,7 +8,6 @@ import android.view.View; import android.widget.FrameLayout; import androidx.annotation.NonNull; -import androidx.appcompat.widget.Toolbar; import androidx.lifecycle.ViewModelProviders; import androidx.recyclerview.widget.RecyclerView; @@ -16,7 +15,7 @@ import org.thoughtcrime.securesms.PassphraseRequiredActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper; 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.ui.error.SafetyNumberChangeDialog; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; @@ -46,6 +45,7 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity { private MessageDetailsViewModel viewModel; private MessageDetailsAdapter adapter; private Colorizer colorizer; + private RecyclerViewColorizer recyclerViewColorizer; private DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); @@ -100,16 +100,15 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity { private void initializeList() { RecyclerView list = findViewById(R.id.message_details_list); - ColorizerView colorizerView = findViewById(R.id.message_details_colorizer); View toolbarShadow = findViewById(R.id.toolbar_shadow); - colorizer = new Colorizer(colorizerView); - adapter = new MessageDetailsAdapter(this, glideRequests, colorizer, this::onErrorClicked); + colorizer = new Colorizer(); + adapter = new MessageDetailsAdapter(this, glideRequests, colorizer, this::onErrorClicked); + recyclerViewColorizer = new RecyclerViewColorizer(list); list.setAdapter(adapter); list.setItemAnimator(null); list.addOnScrollListener(new ToolbarShadowAnimationHelper(toolbarShadow)); - colorizer.attachToRecyclerView(list); } private void initializeViewModel() { @@ -126,7 +125,7 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity { adapter.submitList(convertToRows(details)); } }); - viewModel.getRecipient().observe(this, recipient -> colorizer.onChatColorsChanged(recipient.getChatColors())); + viewModel.getRecipient().observe(this, recipient -> recyclerViewColorizer.setChatColors(recipient.getChatColors())); } private void initializeVideoPlayer() { diff --git a/app/src/main/res/layout/conversation_fragment.xml b/app/src/main/res/layout/conversation_fragment.xml index ea688d4191..173fa8a619 100644 --- a/app/src/main/res/layout/conversation_fragment.xml +++ b/app/src/main/res/layout/conversation_fragment.xml @@ -6,16 +6,6 @@ android:layout_width="fill_parent" android:layout_height="match_parent"> - - - -