Fix stretchy chat colors on Android 12.

This commit is contained in:
Alex Hart 2021-09-27 15:04:44 -03:00 committed by Cody Henthorne
parent e637f15a43
commit bad382e2f3
10 changed files with 173 additions and 90 deletions

View file

@ -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)); 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) { 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))); return new Point(xPrime(origin, corner, Math.toRadians(degrees)), yPrime(origin, corner, Math.toRadians(degrees)));
} }

View file

@ -88,7 +88,7 @@ import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity;
import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener; import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener;
import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder; import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder;
import org.thoughtcrime.securesms.conversation.colors.Colorizer; 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.MultiselectItemAnimator;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
@ -218,7 +218,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
private OnScrollListener conversationScrollListener; private OnScrollListener conversationScrollListener;
private int pulsePosition = -1; private int pulsePosition = -1;
private View toolbarShadow; private View toolbarShadow;
private ColorizerView colorizerView;
private Stopwatch startupStopwatch; private Stopwatch startupStopwatch;
private GiphyMp4ProjectionRecycler giphyMp4ProjectionRecycler; private GiphyMp4ProjectionRecycler giphyMp4ProjectionRecycler;
@ -256,10 +255,8 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
scrollToMentionButton = view.findViewById(R.id.scroll_to_mention); scrollToMentionButton = view.findViewById(R.id.scroll_to_mention);
scrollDateHeader = view.findViewById(R.id.scroll_date_header); scrollDateHeader = view.findViewById(R.id.scroll_date_header);
toolbarShadow = requireActivity().findViewById(R.id.conversation_toolbar_shadow); toolbarShadow = requireActivity().findViewById(R.id.conversation_toolbar_shadow);
colorizerView = view.findViewById(R.id.conversation_colorizer_view);
ConversationIntents.Args args = ConversationIntents.Args.from(requireActivity().getIntent()); ConversationIntents.Args args = ConversationIntents.Args.from(requireActivity().getIntent());
colorizerView.setBackground(args.getChatColors().getChatBubbleMask());
final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true); final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true);
final MultiselectItemAnimator multiselectItemAnimator = new MultiselectItemAnimator(() -> { final MultiselectItemAnimator multiselectItemAnimator = new MultiselectItemAnimator(() -> {
@ -351,10 +348,10 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
updateToolbarDependentMargins(); updateToolbarDependentMargins();
colorizer = new Colorizer(colorizerView); colorizer = new Colorizer();
colorizer.attachToRecyclerView(list); RecyclerViewColorizer recyclerViewColorizer = new RecyclerViewColorizer(list);
conversationViewModel.getChatColors().observe(getViewLifecycleOwner(), chatColors -> colorizer.onChatColorsChanged(chatColors)); conversationViewModel.getChatColors().observe(getViewLifecycleOwner(), recyclerViewColorizer::setChatColors);
conversationViewModel.getNameColorsMap().observe(getViewLifecycleOwner(), nameColorsMap -> { conversationViewModel.getNameColorsMap().observe(getViewLifecycleOwner(), nameColorsMap -> {
colorizer.onNameColorsChanged(nameColorsMap); colorizer.onNameColorsChanged(nameColorsMap);
@ -412,12 +409,10 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
private void setListVerticalTranslation() { private void setListVerticalTranslation() {
if (list.canScrollVertically(1) || list.canScrollVertically(-1) || list.getChildCount() == 0) { if (list.canScrollVertically(1) || list.canScrollVertically(-1) || list.getChildCount() == 0) {
list.setTranslationY(0); list.setTranslationY(0);
colorizerView.setTranslationY(0);
list.setOverScrollMode(RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS); list.setOverScrollMode(RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS);
} else { } else {
int chTop = list.getChildAt(list.getChildCount() - 1).getTop(); int chTop = list.getChildAt(list.getChildCount() - 1).getTop();
list.setTranslationY(Math.min(0, -chTop)); list.setTranslationY(Math.min(0, -chTop));
colorizerView.setTranslationY(Math.min(0, -chTop));
list.setOverScrollMode(RecyclerView.OVER_SCROLL_NEVER); list.setOverScrollMode(RecyclerView.OVER_SCROLL_NEVER);
} }
@ -539,10 +534,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
if (viewHolder instanceof GiphyMp4Playable) { if (viewHolder instanceof GiphyMp4Playable) {
giphyMp4ProjectionRecycler.updateVideoDisplayPositionAndSize(recyclerView, (GiphyMp4Playable) viewHolder); giphyMp4ProjectionRecycler.updateVideoDisplayPositionAndSize(recyclerView, (GiphyMp4Playable) viewHolder);
} }
if (colorizer != null) {
colorizer.applyClipPathsToMaskedGradient(recyclerView);
}
} }
private int getStartPosition() { private int getStartPosition() {

View file

@ -1718,7 +1718,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (messageRecord.isOutgoing() && if (messageRecord.isOutgoing() &&
!hasNoBubble(messageRecord) && !hasNoBubble(messageRecord) &&
!messageRecord.isRemoteDelete() && !messageRecord.isRemoteDelete() &&
bodyBubbleCorners != null) bodyBubbleCorners != null &&
bodyBubble.getProjections().isEmpty())
{ {
projections.add(Projection.relativeToViewRoot(bodyBubble, bodyBubbleCorners).translateX(bodyBubble.getTranslationX())); projections.add(Projection.relativeToViewRoot(bodyBubble, bodyBubbleCorners).translateX(bodyBubble.getTranslationX()));
} }

View file

@ -5,6 +5,7 @@ import android.graphics.ColorFilter
import android.graphics.Path import android.graphics.Path
import android.graphics.PorterDuff import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter import android.graphics.PorterDuffColorFilter
import android.graphics.Shader
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.graphics.drawable.ShapeDrawable 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. * Returns the ColorFilter to apply to a conversation bubble or other relevant piece of UI.
*/ */

View file

@ -2,16 +2,11 @@ package org.thoughtcrime.securesms.conversation.colors
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.os.Build
import android.view.View
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.Projection
/** /**
* Helper class for all things ChatColors. * Helper class for all things ChatColors.
@ -20,7 +15,7 @@ import org.thoughtcrime.securesms.util.Projection
* - Gives easy access to different bubble colors * - Gives easy access to different bubble colors
* - Watches and responds to RecyclerView scroll and layout changes to update a ColorizerView * - 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 var colorsHaveBeenSet = false
private val groupSenderColors: MutableMap<RecipientId, NameColor> = mutableMapOf() private val groupSenderColors: MutableMap<RecipientId, NameColor> = mutableMapOf()
@ -43,55 +38,12 @@ class Colorizer(private val colorizerView: ColorizerView) : RecyclerView.OnScrol
@ColorInt @ColorInt
fun getIncomingGroupSenderColor(context: Context, recipient: Recipient): Int = groupSenderColors[recipient.id]?.getColor(context) ?: getDefaultColor(context, recipient.id) 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>) { fun onNameColorsChanged(nameColorMap: Map<RecipientId, NameColor>) {
groupSenderColors.clear() groupSenderColors.clear()
groupSenderColors.putAll(nameColorMap) groupSenderColors.putAll(nameColorMap)
colorsHaveBeenSet = true 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 @ColorInt
private fun getDefaultColor(context: Context, recipientId: RecipientId): Int { private fun getDefaultColor(context: Context, recipientId: RecipientId): Int {
return if (colorsHaveBeenSet) { return if (colorsHaveBeenSet) {

View file

@ -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
}
}

View file

@ -101,7 +101,7 @@ class ChatColorPreviewView @JvmOverloads constructor(
wallpaper = findViewById(R.id.wallpaper) wallpaper = findViewById(R.id.wallpaper)
wallpaperDim = findViewById(R.id.wallpaper_dim) wallpaperDim = findViewById(R.id.wallpaper_dim)
colorizerView = findViewById(R.id.colorizer) colorizerView = findViewById(R.id.colorizer)
colorizer = Colorizer(colorizerView) colorizer = Colorizer()
} finally { } finally {
typedArray?.recycle() typedArray?.recycle()
} }

View file

@ -8,7 +8,6 @@ import android.view.View;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import androidx.lifecycle.ViewModelProviders; import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
@ -16,7 +15,7 @@ import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper; import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper;
import org.thoughtcrime.securesms.conversation.colors.Colorizer; 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.conversation.ui.error.SafetyNumberChangeDialog;
import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
@ -46,6 +45,7 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity {
private MessageDetailsViewModel viewModel; private MessageDetailsViewModel viewModel;
private MessageDetailsAdapter adapter; private MessageDetailsAdapter adapter;
private Colorizer colorizer; private Colorizer colorizer;
private RecyclerViewColorizer recyclerViewColorizer;
private DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); private DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
@ -100,16 +100,15 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity {
private void initializeList() { private void initializeList() {
RecyclerView list = findViewById(R.id.message_details_list); RecyclerView list = findViewById(R.id.message_details_list);
ColorizerView colorizerView = findViewById(R.id.message_details_colorizer);
View toolbarShadow = findViewById(R.id.toolbar_shadow); View toolbarShadow = findViewById(R.id.toolbar_shadow);
colorizer = new Colorizer(colorizerView); colorizer = new Colorizer();
adapter = new MessageDetailsAdapter(this, glideRequests, colorizer, this::onErrorClicked); adapter = new MessageDetailsAdapter(this, glideRequests, colorizer, this::onErrorClicked);
recyclerViewColorizer = new RecyclerViewColorizer(list);
list.setAdapter(adapter); list.setAdapter(adapter);
list.setItemAnimator(null); list.setItemAnimator(null);
list.addOnScrollListener(new ToolbarShadowAnimationHelper(toolbarShadow)); list.addOnScrollListener(new ToolbarShadowAnimationHelper(toolbarShadow));
colorizer.attachToRecyclerView(list);
} }
private void initializeViewModel() { private void initializeViewModel() {
@ -126,7 +125,7 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity {
adapter.submitList(convertToRows(details)); 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() { private void initializeVideoPlayer() {

View file

@ -6,16 +6,6 @@
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<org.thoughtcrime.securesms.conversation.colors.ColorizerView
android:id="@+id/conversation_colorizer_view"
android:background="@drawable/test_gradient"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="@android:id/list"
app:layout_constraintEnd_toEndOf="@android:id/list"
app:layout_constraintStart_toStartOf="@android:id/list"
app:layout_constraintTop_toTopOf="@android:id/list" />
<FrameLayout <FrameLayout
android:id="@+id/video_container" android:id="@+id/video_container"
android:layout_width="0dp" android:layout_width="0dp"

View file

@ -19,15 +19,6 @@
app:navigationContentDescription="@string/DSLSettingsToolbar__navigate_up" app:navigationContentDescription="@string/DSLSettingsToolbar__navigate_up"
tools:title="Message Details" /> tools:title="Message Details" />
<org.thoughtcrime.securesms.conversation.colors.ColorizerView
android:id="@+id/message_details_colorizer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
<FrameLayout <FrameLayout
android:id="@+id/video_container" android:id="@+id/video_container"
android:layout_width="0dp" android:layout_width="0dp"