mirror of
https://github.com/oxen-io/session-android.git
synced 2025-12-31 13:16:16 +00:00
Add initial Mentions UI/UX for picker and compose edit.
This commit is contained in:
committed by
Greyson Parrelli
parent
8e45a546c9
commit
1ab61beeb9
@@ -2,40 +2,50 @@ package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Canvas;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat;
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat;
|
||||
import androidx.core.view.inputmethod.InputContentInfoCompat;
|
||||
import androidx.core.os.BuildCompat;
|
||||
|
||||
import android.text.Editable;
|
||||
import android.text.InputType;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextUtils.TruncateAt;
|
||||
import android.text.method.QwertyKeyListener;
|
||||
import android.text.style.RelativeSizeSpan;
|
||||
import android.util.AttributeSet;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.os.BuildCompat;
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat;
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat;
|
||||
import androidx.core.view.inputmethod.InputContentInfoCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.TransportOption;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class ComposeText extends EmojiEditText {
|
||||
|
||||
private CharSequence hint;
|
||||
private SpannableString subHint;
|
||||
private CharSequence hint;
|
||||
private SpannableString subHint;
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
|
||||
@Nullable private InputPanel.MediaListener mediaListener;
|
||||
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
|
||||
@Nullable private MentionQueryChangedListener mentionQueryChangedListener;
|
||||
|
||||
public ComposeText(Context context) {
|
||||
super(context);
|
||||
@@ -75,11 +85,33 @@ public class ComposeText extends EmojiEditText {
|
||||
protected void onSelectionChanged(int selStart, int selEnd) {
|
||||
super.onSelectionChanged(selStart, selEnd);
|
||||
|
||||
if (FeatureFlags.mentions()) {
|
||||
if (selStart == selEnd) {
|
||||
doAfterCursorChange();
|
||||
} else {
|
||||
updateQuery("");
|
||||
}
|
||||
}
|
||||
|
||||
if (cursorPositionChangedListener != null) {
|
||||
cursorPositionChangedListener.onCursorPositionChanged(selStart, selEnd);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
if (FeatureFlags.mentions() && getText() != null && getLayout() != null) {
|
||||
int checkpoint = canvas.save();
|
||||
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
|
||||
try {
|
||||
mentionRendererDelegate.draw(canvas, getText(), getLayout());
|
||||
} finally {
|
||||
canvas.restoreToCount(checkpoint);
|
||||
}
|
||||
}
|
||||
super.onDraw(canvas);
|
||||
}
|
||||
|
||||
private CharSequence ellipsizeToWidth(CharSequence text) {
|
||||
return TextUtils.ellipsize(text,
|
||||
getPaint(),
|
||||
@@ -119,6 +151,10 @@ public class ComposeText extends EmojiEditText {
|
||||
this.cursorPositionChangedListener = listener;
|
||||
}
|
||||
|
||||
public void setMentionQueryChangedListener(@Nullable MentionQueryChangedListener listener) {
|
||||
this.mentionQueryChangedListener = listener;
|
||||
}
|
||||
|
||||
private boolean isLandscape() {
|
||||
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
|
||||
}
|
||||
@@ -169,9 +205,89 @@ public class ComposeText extends EmojiEditText {
|
||||
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
|
||||
setImeOptions(getImeOptions() | 16777216);
|
||||
}
|
||||
|
||||
if (FeatureFlags.mentions()) {
|
||||
mentionRendererDelegate = new MentionRendererDelegate(getContext());
|
||||
}
|
||||
}
|
||||
|
||||
private void doAfterCursorChange() {
|
||||
Editable text = getText();
|
||||
if (text != null && enoughToFilter(text)) {
|
||||
performFiltering(text);
|
||||
} else {
|
||||
updateQuery("");
|
||||
}
|
||||
}
|
||||
|
||||
private void performFiltering(@NonNull Editable text) {
|
||||
int end = getSelectionEnd();
|
||||
int start = findQueryStart(text, end);
|
||||
CharSequence query = text.subSequence(start, end);
|
||||
updateQuery(query);
|
||||
}
|
||||
|
||||
private void updateQuery(@NonNull CharSequence query) {
|
||||
if (mentionQueryChangedListener != null) {
|
||||
mentionQueryChangedListener.onQueryChanged(query);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean enoughToFilter(@NonNull Editable text) {
|
||||
int end = getSelectionEnd();
|
||||
if (end < 0) {
|
||||
return false;
|
||||
}
|
||||
return end - findQueryStart(text, end) >= 1;
|
||||
}
|
||||
|
||||
public void replaceTextWithMention(@NonNull String displayName, @NonNull UUID uuid) {
|
||||
Editable text = getText();
|
||||
if (text == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearComposingText();
|
||||
|
||||
int end = getSelectionEnd();
|
||||
int start = findQueryStart(text, end) - 1;
|
||||
String original = TextUtils.substring(text, start, end);
|
||||
|
||||
QwertyKeyListener.markAsReplaced(text, start, end, original);
|
||||
text.replace(start, end, createReplacementToken(displayName, uuid));
|
||||
}
|
||||
|
||||
private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull UUID uuid) {
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder("@");
|
||||
if (text instanceof Spanned) {
|
||||
SpannableString spannableString = new SpannableString(text + " ");
|
||||
TextUtils.copySpansFrom((Spanned) text, 0, text.length(), Object.class, spannableString, 0);
|
||||
builder.append(spannableString);
|
||||
} else {
|
||||
builder.append(text).append(" ");
|
||||
}
|
||||
|
||||
builder.setSpan(MentionAnnotation.mentionAnnotationForUuid(uuid), 0, builder.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private int findQueryStart(@NonNull CharSequence text, int inputCursorPosition) {
|
||||
if (inputCursorPosition == 0) {
|
||||
return inputCursorPosition;
|
||||
}
|
||||
|
||||
int delimiterSearchIndex = inputCursorPosition - 1;
|
||||
while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != '@' && text.charAt(delimiterSearchIndex) != ' ')) {
|
||||
delimiterSearchIndex--;
|
||||
}
|
||||
|
||||
if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == '@') {
|
||||
return delimiterSearchIndex + 1;
|
||||
}
|
||||
return inputCursorPosition;
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.HONEYCOMB_MR2)
|
||||
private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener {
|
||||
|
||||
private static final String TAG = CommitContentListener.class.getSimpleName();
|
||||
@@ -207,4 +323,8 @@ public class ComposeText extends EmojiEditText {
|
||||
public interface CursorPositionChangedListener {
|
||||
void onCursorPositionChanged(int start, int end);
|
||||
}
|
||||
|
||||
public interface MentionQueryChangedListener {
|
||||
void onQueryChanged(CharSequence query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.thoughtcrime.securesms.components.mention;
|
||||
|
||||
|
||||
import android.text.Annotation;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Factory for creating mention annotation spans.
|
||||
*
|
||||
* Note: This wraps creating an Android standard {@link Annotation} so it can leverage the built in
|
||||
* span parceling for copy/paste. Do not extend Annotation or this will be lost.
|
||||
*/
|
||||
public final class MentionAnnotation {
|
||||
|
||||
public static final String MENTION_ANNOTATION = "mention";
|
||||
|
||||
private MentionAnnotation() {
|
||||
}
|
||||
|
||||
public static Annotation mentionAnnotationForUuid(@NonNull UUID uuid) {
|
||||
return new Annotation(MENTION_ANNOTATION, uuid.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package org.thoughtcrime.securesms.components.mention;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.Layout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.util.LayoutUtil;
|
||||
|
||||
/**
|
||||
* Handles actually drawing the mention backgrounds for a TextView.
|
||||
* <p>
|
||||
* Ported and modified from https://github.com/googlearchive/android-text/tree/master/RoundedBackground-Kotlin
|
||||
*/
|
||||
public abstract class MentionRenderer {
|
||||
|
||||
protected final int horizontalPadding;
|
||||
protected final int verticalPadding;
|
||||
|
||||
public MentionRenderer(int horizontalPadding, int verticalPadding) {
|
||||
this.horizontalPadding = horizontalPadding;
|
||||
this.verticalPadding = verticalPadding;
|
||||
}
|
||||
|
||||
public abstract void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset);
|
||||
|
||||
protected int getLineTop(@NonNull Layout layout, int line) {
|
||||
return LayoutUtil.getLineTopWithoutPadding(layout, line) - verticalPadding;
|
||||
}
|
||||
|
||||
protected int getLineBottom(@NonNull Layout layout, int line) {
|
||||
return LayoutUtil.getLineBottomWithoutPadding(layout, line) + verticalPadding;
|
||||
}
|
||||
|
||||
public static final class SingleLineMentionRenderer extends MentionRenderer {
|
||||
|
||||
private final Drawable drawable;
|
||||
|
||||
public SingleLineMentionRenderer(int horizontalPadding, int verticalPadding, @NonNull Drawable drawable) {
|
||||
super(horizontalPadding, verticalPadding);
|
||||
this.drawable = drawable;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset) {
|
||||
int lineTop = getLineTop(layout, startLine);
|
||||
int lineBottom = getLineBottom(layout, startLine);
|
||||
int left = Math.min(startOffset, endOffset);
|
||||
int right = Math.max(startOffset, endOffset);
|
||||
|
||||
drawable.setBounds(left, lineTop, right, lineBottom);
|
||||
drawable.draw(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class MultiLineMentionRenderer extends MentionRenderer {
|
||||
|
||||
private final Drawable drawableLeft;
|
||||
private final Drawable drawableMid;
|
||||
private final Drawable drawableRight;
|
||||
|
||||
public MultiLineMentionRenderer(int horizontalPadding, int verticalPadding,
|
||||
@NonNull Drawable drawableLeft,
|
||||
@NonNull Drawable drawableMid,
|
||||
@NonNull Drawable drawableRight)
|
||||
{
|
||||
super(horizontalPadding, verticalPadding);
|
||||
this.drawableLeft = drawableLeft;
|
||||
this.drawableMid = drawableMid;
|
||||
this.drawableRight = drawableRight;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset) {
|
||||
int paragraphDirection = layout.getParagraphDirection(startLine);
|
||||
|
||||
float lineEndOffset;
|
||||
if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) {
|
||||
lineEndOffset = layout.getLineLeft(startLine) - horizontalPadding;
|
||||
} else {
|
||||
lineEndOffset = layout.getLineRight(startLine) + horizontalPadding;
|
||||
}
|
||||
|
||||
int lineBottom = getLineBottom(layout, startLine);
|
||||
int lineTop = getLineTop(layout, startLine);
|
||||
drawStart(canvas, startOffset, lineTop, (int) lineEndOffset, lineBottom);
|
||||
|
||||
for (int line = startLine + 1; line < endLine; line++) {
|
||||
int left = (int) layout.getLineLeft(line) - horizontalPadding;
|
||||
int right = (int) layout.getLineRight(line) + horizontalPadding;
|
||||
|
||||
lineTop = getLineTop(layout, line);
|
||||
lineBottom = getLineBottom(layout, line);
|
||||
|
||||
drawableMid.setBounds(left, lineTop, right, lineBottom);
|
||||
drawableMid.draw(canvas);
|
||||
}
|
||||
|
||||
float lineStartOffset;
|
||||
if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) {
|
||||
lineStartOffset = layout.getLineRight(startLine) + horizontalPadding;
|
||||
} else {
|
||||
lineStartOffset = layout.getLineLeft(startLine) - horizontalPadding;
|
||||
}
|
||||
|
||||
lineBottom = getLineBottom(layout, endLine);
|
||||
lineTop = getLineTop(layout, endLine);
|
||||
|
||||
drawEnd(canvas, (int) lineStartOffset, lineTop, endOffset, lineBottom);
|
||||
}
|
||||
|
||||
private void drawStart(@NonNull Canvas canvas, int start, int top, int end, int bottom) {
|
||||
if (start > end) {
|
||||
drawableRight.setBounds(end, top, start, bottom);
|
||||
drawableRight.draw(canvas);
|
||||
} else {
|
||||
drawableLeft.setBounds(start, top, end, bottom);
|
||||
drawableLeft.draw(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawEnd(@NonNull Canvas canvas, int start, int top, int end, int bottom) {
|
||||
if (start > end) {
|
||||
drawableLeft.setBounds(end, top, start, bottom);
|
||||
drawableLeft.draw(canvas);
|
||||
} else {
|
||||
drawableRight.setBounds(start, top, end, bottom);
|
||||
drawableRight.draw(canvas);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.thoughtcrime.securesms.components.mention;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.Annotation;
|
||||
import android.text.Layout;
|
||||
import android.text.Spanned;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
/**
|
||||
* Encapsulates the logic for determining the type of mention rendering needed (single vs multi-line) and then
|
||||
* passing that information to the appropriate {@link MentionRenderer}.
|
||||
* <p></p>
|
||||
* Ported and modified from https://github.com/googlearchive/android-text/tree/master/RoundedBackground-Kotlin
|
||||
*/
|
||||
public class MentionRendererDelegate {
|
||||
|
||||
private final MentionRenderer single;
|
||||
private final MentionRenderer multi;
|
||||
private final int horizontalPadding;
|
||||
|
||||
public MentionRendererDelegate(@NonNull Context context) {
|
||||
//noinspection ConstantConditions
|
||||
this(ViewUtil.dpToPx(2),
|
||||
ViewUtil.dpToPx(2),
|
||||
ContextCompat.getDrawable(context, R.drawable.mention_text_bg),
|
||||
ContextCompat.getDrawable(context, R.drawable.mention_text_bg_left),
|
||||
ContextCompat.getDrawable(context, R.drawable.mention_text_bg_mid),
|
||||
ContextCompat.getDrawable(context, R.drawable.mention_text_bg_right),
|
||||
ThemeUtil.getThemedColor(context, R.attr.conversation_mention_background_color));
|
||||
}
|
||||
|
||||
public MentionRendererDelegate(int horizontalPadding,
|
||||
int verticalPadding,
|
||||
@NonNull Drawable drawable,
|
||||
@NonNull Drawable drawableLeft,
|
||||
@NonNull Drawable drawableMid,
|
||||
@NonNull Drawable drawableEnd,
|
||||
@ColorInt int tint)
|
||||
{
|
||||
this.horizontalPadding = horizontalPadding;
|
||||
single = new MentionRenderer.SingleLineMentionRenderer(horizontalPadding,
|
||||
verticalPadding,
|
||||
DrawableUtil.tint(drawable, tint));
|
||||
multi = new MentionRenderer.MultiLineMentionRenderer(horizontalPadding,
|
||||
verticalPadding,
|
||||
DrawableUtil.tint(drawableLeft, tint),
|
||||
DrawableUtil.tint(drawableMid, tint),
|
||||
DrawableUtil.tint(drawableEnd, tint));
|
||||
}
|
||||
|
||||
public void draw(@NonNull Canvas canvas, @NonNull Spanned text, @NonNull Layout layout) {
|
||||
Annotation[] spans = text.getSpans(0, text.length(), Annotation.class);
|
||||
for (Annotation span : spans) {
|
||||
if (MentionAnnotation.MENTION_ANNOTATION.equals(span.getKey())) {
|
||||
int spanStart = text.getSpanStart(span);
|
||||
int spanEnd = text.getSpanEnd(span);
|
||||
int startLine = layout.getLineForOffset(spanStart);
|
||||
int endLine = layout.getLineForOffset(spanEnd);
|
||||
|
||||
int startOffset = (int) (layout.getPrimaryHorizontal(spanStart) + -1 * layout.getParagraphDirection(startLine) * horizontalPadding);
|
||||
int endOffset = (int) (layout.getPrimaryHorizontal(spanEnd) + layout.getParagraphDirection(endLine) * horizontalPadding);
|
||||
|
||||
MentionRenderer renderer = (startLine == endLine) ? single : multi;
|
||||
renderer.draw(canvas, layout, startLine, endLine, startOffset, endOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user