[UI] Add context menus in messages and events to quickly run an action.

This commit is contained in:
Kuba Szczodrzyński 2020-03-19 17:55:12 +01:00
parent a082d95b04
commit c95bc656ea
12 changed files with 792 additions and 24 deletions

View File

@ -1,21 +0,0 @@
/*
* Copyright (c) Kuba Szczodrzyński 2019-11-11.
*/
package pl.szczodrzynski.edziennik;
import android.graphics.Paint;
import android.widget.TextView;
import androidx.databinding.BindingAdapter;
public class Binding {
@BindingAdapter("strikeThrough")
public static void strikeThrough(TextView textView, Boolean strikeThrough) {
if (strikeThrough) {
textView.setPaintFlags(textView.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG);
} else {
textView.setPaintFlags(textView.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG);
}
}
}

View File

@ -0,0 +1,20 @@
/*
* Copyright (c) Kuba Szczodrzyński 2019-11-11.
*/
package pl.szczodrzynski.edziennik
import android.graphics.Paint
import android.widget.TextView
import androidx.databinding.BindingAdapter
object Binding {
@JvmStatic
@BindingAdapter("strikeThrough")
fun strikeThrough(textView: TextView, strikeThrough: Boolean) {
if (strikeThrough) {
textView.paintFlags = textView.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
} else {
textView.paintFlags = textView.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
}
}
}

View File

@ -2,6 +2,8 @@ package pl.szczodrzynski.edziennik
import android.Manifest
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
@ -10,6 +12,7 @@ import android.content.res.Resources
import android.database.Cursor
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.os.Build
@ -23,6 +26,7 @@ import android.util.Base64
import android.util.Base64.NO_WRAP
import android.util.Base64.encodeToString
import android.view.View
import android.view.WindowManager
import android.widget.CheckBox
import android.widget.CompoundButton
import android.widget.RadioButton
@ -1094,3 +1098,71 @@ inline fun <A, B, R> ifNotNull(a: A?, b: B?, code: (A, B) -> R): R? {
fun Iterable<Int>.averageOrNull() = this.average().let { if (it.isNaN()) null else it }
@kotlin.jvm.JvmName("averageOrNullOfFloat")
fun Iterable<Float>.averageOrNull() = this.average().let { if (it.isNaN()) null else it }
fun String.copyToClipboard(context: Context) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clipData = ClipData.newPlainText("Tekst", this)
clipboard.primaryClip = clipData
}
fun TextView.getTextPosition(range: IntRange): Rect {
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
// Initialize global value
var parentTextViewRect = Rect()
// Initialize values for the computing of clickedText position
//val completeText = parentTextView.text as SpannableString
val textViewLayout = this.layout
val startOffsetOfClickedText = range.first//completeText.getSpanStart(clickedText)
val endOffsetOfClickedText = range.last//completeText.getSpanEnd(clickedText)
var startXCoordinatesOfClickedText = textViewLayout.getPrimaryHorizontal(startOffsetOfClickedText)
var endXCoordinatesOfClickedText = textViewLayout.getPrimaryHorizontal(endOffsetOfClickedText)
// Get the rectangle of the clicked text
val currentLineStartOffset = textViewLayout.getLineForOffset(startOffsetOfClickedText)
val currentLineEndOffset = textViewLayout.getLineForOffset(endOffsetOfClickedText)
val keywordIsInMultiLine = currentLineStartOffset != currentLineEndOffset
textViewLayout.getLineBounds(currentLineStartOffset, parentTextViewRect)
// Update the rectangle position to his real position on screen
val parentTextViewLocation = intArrayOf(0, 0)
this.getLocationOnScreen(parentTextViewLocation)
val parentTextViewTopAndBottomOffset = (parentTextViewLocation[1] - this.scrollY + this.compoundPaddingTop)
parentTextViewRect.top += parentTextViewTopAndBottomOffset
parentTextViewRect.bottom += parentTextViewTopAndBottomOffset
// In the case of multi line text, we have to choose what rectangle take
if (keywordIsInMultiLine) {
val screenHeight = windowManager.defaultDisplay.height
val dyTop = parentTextViewRect.top
val dyBottom = screenHeight - parentTextViewRect.bottom
val onTop = dyTop > dyBottom
if (onTop) {
endXCoordinatesOfClickedText = textViewLayout.getLineRight(currentLineStartOffset);
} else {
parentTextViewRect = Rect()
textViewLayout.getLineBounds(currentLineEndOffset, parentTextViewRect);
parentTextViewRect.top += parentTextViewTopAndBottomOffset;
parentTextViewRect.bottom += parentTextViewTopAndBottomOffset;
startXCoordinatesOfClickedText = textViewLayout.getLineLeft(currentLineEndOffset);
}
}
parentTextViewRect.left += (
parentTextViewLocation[0] +
startXCoordinatesOfClickedText +
this.compoundPaddingLeft -
this.scrollX
).toInt()
parentTextViewRect.right = (
parentTextViewRect.left +
endXCoordinatesOfClickedText -
startXCoordinatesOfClickedText
).toInt()
return parentTextViewRect
}

View File

@ -49,6 +49,7 @@ import pl.szczodrzynski.edziennik.sync.SyncWorker
import pl.szczodrzynski.edziennik.sync.UpdateWorker
import pl.szczodrzynski.edziennik.ui.dialogs.ServerMessageDialog
import pl.szczodrzynski.edziennik.ui.dialogs.changelog.ChangelogDialog
import pl.szczodrzynski.edziennik.ui.dialogs.event.EventManualDialog
import pl.szczodrzynski.edziennik.ui.dialogs.settings.ProfileRemoveDialog
import pl.szczodrzynski.edziennik.ui.dialogs.sync.SyncViewListDialog
import pl.szczodrzynski.edziennik.ui.modules.agenda.AgendaFragment
@ -723,6 +724,15 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
)
true
}
"createManualEvent" -> {
val date = extras.getString("eventDate")?.let { Date.fromY_m_d(it) } ?: Date.getToday()
EventManualDialog(
this,
App.profileId,
defaultDate = date
)
true
}
else -> false
}
if (handled && !navLoading) {

View File

@ -5,6 +5,7 @@
package pl.szczodrzynski.edziennik.data.api
import kotlin.text.RegexOption.DOT_MATCHES_ALL
import kotlin.text.RegexOption.IGNORE_CASE
object Regexes {
val STYLE_CSS_COLOR by lazy {
@ -199,4 +200,20 @@ object Regexes {
val EDUDZIENNIK_TEACHERS by lazy {
"""<div class="teacher">.*?<p>(.+?) (.+?)</p>""".toRegex(DOT_MATCHES_ALL)
}
val LINKIFY_DATE_YMD by lazy {
"""(1\d{3}|20\d{2})[\-./](1[0-2]|0?\d)[\-./]([1-2]\d|3[0-1]|0?\d)""".toRegex()
}
val LINKIFY_DATE_DMY by lazy {
"""(?<![\d\-./])([1-2]\d|3[0-1]|0?\d)[\-./](1[0-2]|0?\d)(?:[\-./](1\d{3}|2?0?\d{2}))?(?![\d\-/])""".toRegex()
}
val LINKIFY_DATE_ABSOLUTE by lazy {
"""([1-3][0-9]|[1-9])\s(sty|lut|mar|kwi|maj|cze|lip|sie|wrz|paź|lis|gru).*?\s(1[0-9]{3}|20[0-9]{2})?""".toRegex(IGNORE_CASE)
}
val LINKIFY_DATE_RELATIVE by lazy {
"""za\s([0-9]+)?\s?(dni|dzień|tydzień|tygodnie)""".toRegex(IGNORE_CASE)
}
}

View File

@ -19,6 +19,7 @@ import pl.szczodrzynski.edziennik.data.api.szkolny.SzkolnyApi
import pl.szczodrzynski.edziennik.data.db.full.EventFull
import pl.szczodrzynski.edziennik.databinding.DialogEventDetailsBinding
import pl.szczodrzynski.edziennik.ui.modules.timetable.TimetableFragment
import pl.szczodrzynski.edziennik.utils.BetterLink
import pl.szczodrzynski.edziennik.utils.models.Date
import kotlin.coroutines.CoroutineContext
@ -159,6 +160,11 @@ class EventDetailsDialog(
Toast.makeText(activity, R.string.hint_edit_event, Toast.LENGTH_SHORT).show()
true
}
b.topic.text = event.topic
BetterLink.attach(b.topic) {
dialog.dismiss()
}
}
private fun showRemovingProgressDialog() {

View File

@ -41,6 +41,7 @@ import pl.szczodrzynski.edziennik.data.db.entity.Message.TYPE_SENT
import pl.szczodrzynski.edziennik.data.db.full.MessageFull
import pl.szczodrzynski.edziennik.databinding.MessageFragmentBinding
import pl.szczodrzynski.edziennik.utils.Anim
import pl.szczodrzynski.edziennik.utils.BetterLink
import pl.szczodrzynski.edziennik.utils.Themes
import pl.szczodrzynski.edziennik.utils.Utils
import pl.szczodrzynski.edziennik.utils.Utils.getStringFromFile
@ -251,6 +252,9 @@ class MessageFragment : Fragment(), CoroutineScope {
showAttachments()
BetterLink.attach(b.subject)
BetterLink.attach(b.body)
b.progress.visibility = View.GONE
Anim.fadeIn(b.content, 200, null)
MessagesFragment.pageSelection = min(message.type, 1)

View File

@ -0,0 +1,200 @@
/*
* Copyright (c) Kuba Szczodrzyński 2020-3-18.
*/
@file:Suppress("INACCESSIBLE_TYPE")
package pl.szczodrzynski.edziennik.utils
import android.annotation.SuppressLint
import android.graphics.Color
import android.text.Spannable
import android.text.SpannableString
import android.text.Spanned
import android.text.style.URLSpan
import android.text.util.Linkify
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.appcompat.view.menu.MenuBuilder
import androidx.appcompat.view.menu.MenuPopupHelper
import pl.szczodrzynski.edziennik.Intent
import pl.szczodrzynski.edziennik.copyToClipboard
import pl.szczodrzynski.edziennik.data.api.Regexes
import pl.szczodrzynski.edziennik.get
import pl.szczodrzynski.edziennik.getTextPosition
import pl.szczodrzynski.edziennik.utils.models.Date
object BetterLink {
@SuppressLint("RestrictedApi")
fun attach(textView: TextView, onActionSelected: (() -> Unit)? = null) {
textView.autoLinkMask = Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES
BetterLinkMovementMethod.linkify(textView.autoLinkMask, textView).setOnLinkClickListener { v, span: BetterLinkMovementMethod.ClickableSpanWithText ->
val url = span.text()
val c = v.context
val s = v.text as Spanned
val start = s.getSpanStart(span.span())
val end = s.getSpanEnd(span.span())
val parent = v.rootView.findViewById<ViewGroup>(android.R.id.content)
val parentLocation = intArrayOf(0, 0)
parent.getLocationOnScreen(parentLocation)
val rect = textView.getTextPosition(start..end)
val view = View(c)
view.layoutParams = ViewGroup.LayoutParams(rect.width(), rect.height())
view.setBackgroundColor(Color.TRANSPARENT)
parent.addView(view)
view.x = rect.left.toFloat() - parentLocation[0]
view.y = rect.top.toFloat() - parentLocation[1]
val menu = MenuBuilder(c)
val helper = MenuPopupHelper(c, menu, view)
val popup = helper.popup
var menuTitle = url.substringAfter(":")
var date: Date? = null
var urlItem: MenuItem? = null
var createEventItem: MenuItem? = null
//var goToTimetableItem: MenuItem? = null // TODO 2020-03-19: implement this
var mailItem: MenuItem? = null
var copyItem: MenuItem? = null
when {
url.startsWith("mailto:") -> {
mailItem = menu.add(1, 20, 2, "Napisz e-mail")
}
url.startsWith("dateYmd:") -> {
createEventItem = menu.add(1, 10, 2, "Utwórz wydarzenie")
//goToTimetableItem = menu.add(1, 11, 3, "Idź do planu lekcji")
date = parseDateYmd(menuTitle)
}
url.startsWith("dateDmy:") -> {
createEventItem = menu.add(1, 10, 2, "Utwórz wydarzenie")
//goToTimetableItem = menu.add(1, 11, 3, "Idź do planu lekcji")
date = parseDateDmy(menuTitle)
}
url.startsWith("dateAbs:") -> {
createEventItem = menu.add(1, 10, 2, "Utwórz wydarzenie")
//goToTimetableItem = menu.add(1, 11, 3, "Idź do planu lekcji")
date = parseDateAbs(menuTitle)
}
url.startsWith("dateRel:") -> {
createEventItem = menu.add(1, 10, 2, "Utwórz wydarzenie")
//goToTimetableItem = menu.add(1, 11, 3, "Idź do planu lekcji")
date = parseDateRel(menuTitle)
}
else -> {
urlItem = menu.add(1, 1, 2, "Otwórz w przeglądarce")
menuTitle = url
}
}
copyItem = menu.add(1, 1000, 1000, "Kopiuj tekst")
helper.setOnDismissListener { parent.removeView(view) }
urlItem?.setOnMenuItemClickListener { Utils.openUrl(c, url); true }
mailItem?.setOnMenuItemClickListener { Utils.openUrl(c, url); true }
copyItem?.setOnMenuItemClickListener { menuTitle.copyToClipboard(c); true }
createEventItem?.setOnMenuItemClickListener {
onActionSelected?.invoke()
val intent = Intent(
android.content.Intent.ACTION_MAIN,
"action" to "createManualEvent",
"eventDate" to date?.stringY_m_d
)
c.sendBroadcast(intent)
true
}
menu::class.java.getDeclaredMethod("setHeaderTitleInt", CharSequence::class.java).let {
it.isAccessible = true
it.invoke(menu, menuTitle)
}
popup::class.java.getDeclaredField("mShowTitle").let {
it.isAccessible = true
it.set(popup, true)
}
helper::class.java.getDeclaredMethod("showPopup", Int::class.java, Int::class.java, Boolean::class.java, Boolean::class.java).let {
it.isAccessible = true
it.invoke(helper, 0, 0, false, true)
}
true
}
val spanned = textView.text as? Spannable ?: {
SpannableString(textView.text)
}()
Regexes.LINKIFY_DATE_YMD.findAll(textView.text).forEach { match ->
val span = URLSpan("dateYmd:" + match.value)
spanned.setSpan(span, match.range.first, match.range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
Regexes.LINKIFY_DATE_DMY.findAll(textView.text).forEach { match ->
val span = URLSpan("dateDmy:" + match.value)
spanned.setSpan(span, match.range.first, match.range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
Regexes.LINKIFY_DATE_ABSOLUTE.findAll(textView.text).forEach { match ->
val span = URLSpan("dateAbs:" + match.value)
spanned.setSpan(span, match.range.first, match.range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
Regexes.LINKIFY_DATE_RELATIVE.findAll(textView.text).forEach { match ->
val span = URLSpan("dateRel:" + match.value)
spanned.setSpan(span, match.range.first, match.range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
//Linkify.addLinks(textView, LINKIFY_DATE_ABSOLUTE.toPattern(), "dateAbs:")
//Linkify.addLinks(textView, LINKIFY_DATE_RELATIVE.toPattern(), "dateRel:")
}
private val monthNames = listOf("sty", "lut", "mar", "kwi", "maj", "cze", "lip", "sie", "wrz", "paź", "lis", "gru")
private fun parseDateYmd(text: String): Date? {
return Regexes.LINKIFY_DATE_YMD.find(text)?.let {
val year = it[1].toIntOrNull() ?: Date.getToday().year
val month = it[2].toIntOrNull() ?: 1
val day = it[3].toIntOrNull() ?: 1
Date(year, month, day)
}
}
private fun parseDateDmy(text: String): Date? {
return Regexes.LINKIFY_DATE_DMY.find(text)?.let {
val day = it[1].toIntOrNull() ?: 1
val month = it[2].toIntOrNull() ?: 1
var year = it[3].toIntOrNull() ?: Date.getToday().year
if (year < 50)
year += 2000
Date(year, month, day)
}
}
private fun parseDateAbs(text: String): Date? {
return Regexes.LINKIFY_DATE_ABSOLUTE.find(text)?.let {
val year = it[3].toIntOrNull() ?: Date.getToday().year
val month = monthNames.indexOf(it[2]) + 1
val day = it[1].toIntOrNull() ?: 1
Date(year, month.coerceAtLeast(1), day)
}
}
private fun parseDateRel(text: String): Date? {
return Regexes.LINKIFY_DATE_RELATIVE.find(text)?.let {
val date = Date.getToday()
val amount = it[1].toIntOrNull() ?: 1
val unitInDays = when (it[2]) {
"dni", "dzień" -> 1
"tydzień", "tygodnie" -> 7
else -> 1
}
date.stepForward(0, 0, amount*unitInDays)
}
}
}

View File

@ -0,0 +1,458 @@
package pl.szczodrzynski.edziennik.utils;
import android.app.Activity;
import android.graphics.RectF;
import android.text.Layout;
import android.text.Selection;
import android.text.Spannable;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.style.BackgroundColorSpan;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.view.HapticFeedbackConstants;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.Window;
import android.widget.TextView;
import pl.szczodrzynski.edziennik.R;
/**
* Handles URL clicks on TextViews. Unlike the default implementation, this:
* <p>
* <ul>
* <li>Reliably applies a highlight color on links when they're touched.</li>
* <li>Let's you handle single and long clicks on URLs</li>
* <li>Correctly identifies focused URLs (Unlike the default implementation where a click is registered even if it's
* made outside of the URL's bounds if there is no more text in that direction.)</li>
* </ul>
*/
public class BetterLinkMovementMethod extends LinkMovementMethod {
private static BetterLinkMovementMethod singleInstance;
private static final int LINKIFY_NONE = -2;
private OnLinkClickListener onLinkClickListener;
private OnLinkLongClickListener onLinkLongClickListener;
private final RectF touchedLineBounds = new RectF();
private boolean isUrlHighlighted;
private ClickableSpan clickableSpanUnderTouchOnActionDown;
private int activeTextViewHashcode;
private LongPressTimer ongoingLongPressTimer;
private boolean wasLongPressRegistered;
public interface OnLinkClickListener {
/**
* @param textView The TextView on which a click was registered.
* @param span The clicked URL span.
* @return True if this click was handled. False to let Android handle the URL.
*/
boolean onClick(TextView textView, ClickableSpanWithText span);
}
public interface OnLinkLongClickListener {
/**
* @param textView The TextView on which a long-click was registered.
* @param span The long-clicked URL span.
* @return True if this long-click was handled. False to let Android handle the URL (as a short-click).
*/
boolean onLongClick(TextView textView, ClickableSpanWithText span);
}
/**
* Return a new instance of BetterLinkMovementMethod.
*/
public static BetterLinkMovementMethod newInstance() {
return new BetterLinkMovementMethod();
}
/**
* @param linkifyMask One of {@link Linkify#ALL}, {@link Linkify#PHONE_NUMBERS}, {@link Linkify#MAP_ADDRESSES},
* {@link Linkify#WEB_URLS} and {@link Linkify#EMAIL_ADDRESSES}.
* @param textViews The TextViews on which a {@link BetterLinkMovementMethod} should be registered.
* @return The registered {@link BetterLinkMovementMethod} on the TextViews.
*/
public static BetterLinkMovementMethod linkify(int linkifyMask, TextView... textViews) {
BetterLinkMovementMethod movementMethod = newInstance();
for (TextView textView : textViews) {
addLinks(linkifyMask, movementMethod, textView);
}
return movementMethod;
}
/**
* Like {@link #linkify(int, TextView...)}, but can be used for TextViews with HTML links.
*
* @param textViews The TextViews on which a {@link BetterLinkMovementMethod} should be registered.
* @return The registered {@link BetterLinkMovementMethod} on the TextViews.
*/
public static BetterLinkMovementMethod linkifyHtml(TextView... textViews) {
return linkify(LINKIFY_NONE, textViews);
}
/**
* Recursively register a {@link BetterLinkMovementMethod} on every TextView inside a layout.
*
* @param linkifyMask One of {@link Linkify#ALL}, {@link Linkify#PHONE_NUMBERS}, {@link Linkify#MAP_ADDRESSES},
* {@link Linkify#WEB_URLS} and {@link Linkify#EMAIL_ADDRESSES}.
* @return The registered {@link BetterLinkMovementMethod} on the TextViews.
*/
public static BetterLinkMovementMethod linkify(int linkifyMask, ViewGroup viewGroup) {
BetterLinkMovementMethod movementMethod = newInstance();
rAddLinks(linkifyMask, viewGroup, movementMethod);
return movementMethod;
}
/**
* Like {@link #linkify(int, TextView...)}, but can be used for TextViews with HTML links.
*
* @return The registered {@link BetterLinkMovementMethod} on the TextViews.
*/
@SuppressWarnings("unused")
public static BetterLinkMovementMethod linkifyHtml(ViewGroup viewGroup) {
return linkify(LINKIFY_NONE, viewGroup);
}
/**
* Recursively register a {@link BetterLinkMovementMethod} on every TextView inside a layout.
*
* @param linkifyMask One of {@link Linkify#ALL}, {@link Linkify#PHONE_NUMBERS}, {@link Linkify#MAP_ADDRESSES},
* {@link Linkify#WEB_URLS} and {@link Linkify#EMAIL_ADDRESSES}.
* @return The registered {@link BetterLinkMovementMethod} on the TextViews.
*/
public static BetterLinkMovementMethod linkify(int linkifyMask, Activity activity) {
// Find the layout passed to setContentView().
ViewGroup activityLayout = ((ViewGroup) ((ViewGroup) activity.findViewById(Window.ID_ANDROID_CONTENT)).getChildAt(0));
BetterLinkMovementMethod movementMethod = newInstance();
rAddLinks(linkifyMask, activityLayout, movementMethod);
return movementMethod;
}
/**
* Like {@link #linkify(int, TextView...)}, but can be used for TextViews with HTML links.
*
* @return The registered {@link BetterLinkMovementMethod} on the TextViews.
*/
@SuppressWarnings("unused")
public static BetterLinkMovementMethod linkifyHtml(Activity activity) {
return linkify(LINKIFY_NONE, activity);
}
/**
* Get a static instance of BetterLinkMovementMethod. Do note that registering a click listener on the returned
* instance is not supported because it will potentially be shared on multiple TextViews.
*/
@SuppressWarnings("unused")
public static BetterLinkMovementMethod getInstance() {
if (singleInstance == null) {
singleInstance = new BetterLinkMovementMethod();
}
return singleInstance;
}
protected BetterLinkMovementMethod() {
}
/**
* Set a listener that will get called whenever any link is clicked on the TextView.
*/
public BetterLinkMovementMethod setOnLinkClickListener(OnLinkClickListener clickListener) {
if (this == singleInstance) {
throw new UnsupportedOperationException("Setting a click listener on the instance returned by getInstance() is not supported to avoid memory " +
"leaks. Please use newInstance() or any of the linkify() methods instead.");
}
this.onLinkClickListener = clickListener;
return this;
}
/**
* Set a listener that will get called whenever any link is clicked on the TextView.
*/
public BetterLinkMovementMethod setOnLinkLongClickListener(OnLinkLongClickListener longClickListener) {
if (this == singleInstance) {
throw new UnsupportedOperationException("Setting a long-click listener on the instance returned by getInstance() is not supported to avoid " +
"memory leaks. Please use newInstance() or any of the linkify() methods instead.");
}
this.onLinkLongClickListener = longClickListener;
return this;
}
// ======== PUBLIC APIs END ======== //
private static void rAddLinks(int linkifyMask, ViewGroup viewGroup, BetterLinkMovementMethod movementMethod) {
for (int i = 0; i < viewGroup.getChildCount(); i++) {
View child = viewGroup.getChildAt(i);
if (child instanceof ViewGroup) {
// Recursively find child TextViews.
rAddLinks(linkifyMask, ((ViewGroup) child), movementMethod);
} else if (child instanceof TextView) {
TextView textView = (TextView) child;
addLinks(linkifyMask, movementMethod, textView);
}
}
}
private static void addLinks(int linkifyMask, BetterLinkMovementMethod movementMethod, TextView textView) {
textView.setMovementMethod(movementMethod);
if (linkifyMask != LINKIFY_NONE) {
Linkify.addLinks(textView, linkifyMask);
}
}
@Override
public boolean onTouchEvent(final TextView textView, Spannable text, MotionEvent event) {
if (activeTextViewHashcode != textView.hashCode()) {
// Bug workaround: TextView stops calling onTouchEvent() once any URL is highlighted.
// A hacky solution is to reset any "autoLink" property set in XML. But we also want
// to do this once per TextView.
activeTextViewHashcode = textView.hashCode();
textView.setAutoLinkMask(0);
}
final ClickableSpan clickableSpanUnderTouch = findClickableSpanUnderTouch(textView, text, event);
if (event.getAction() == MotionEvent.ACTION_DOWN) {
clickableSpanUnderTouchOnActionDown = clickableSpanUnderTouch;
}
final boolean touchStartedOverAClickableSpan = clickableSpanUnderTouchOnActionDown != null;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (clickableSpanUnderTouch != null) {
highlightUrl(textView, clickableSpanUnderTouch, text);
}
if (touchStartedOverAClickableSpan && onLinkLongClickListener != null) {
LongPressTimer.OnTimerReachedListener longClickListener = new LongPressTimer.OnTimerReachedListener() {
@Override
public void onTimerReached() {
wasLongPressRegistered = true;
textView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
removeUrlHighlightColor(textView);
dispatchUrlLongClick(textView, clickableSpanUnderTouch);
}
};
startTimerForRegisteringLongClick(textView, longClickListener);
}
return touchStartedOverAClickableSpan;
case MotionEvent.ACTION_UP:
// Register a click only if the touch started and ended on the same URL.
if (!wasLongPressRegistered && touchStartedOverAClickableSpan && clickableSpanUnderTouch == clickableSpanUnderTouchOnActionDown) {
dispatchUrlClick(textView, clickableSpanUnderTouch);
}
cleanupOnTouchUp(textView);
// Consume this event even if we could not find any spans to avoid letting Android handle this event.
// Android's TextView implementation has a bug where links get clicked even when there is no more text
// next to the link and the touch lies outside its bounds in the same direction.
return touchStartedOverAClickableSpan;
case MotionEvent.ACTION_CANCEL:
cleanupOnTouchUp(textView);
return false;
case MotionEvent.ACTION_MOVE:
// Stop listening for a long-press as soon as the user wanders off to unknown lands.
if (clickableSpanUnderTouch != clickableSpanUnderTouchOnActionDown) {
removeLongPressCallback(textView);
}
if (!wasLongPressRegistered) {
// Toggle highlight.
if (clickableSpanUnderTouch != null) {
highlightUrl(textView, clickableSpanUnderTouch, text);
} else {
removeUrlHighlightColor(textView);
}
}
return touchStartedOverAClickableSpan;
default:
return false;
}
}
private void cleanupOnTouchUp(TextView textView) {
wasLongPressRegistered = false;
clickableSpanUnderTouchOnActionDown = null;
removeUrlHighlightColor(textView);
removeLongPressCallback(textView);
}
/**
* Determines the touched location inside the TextView's text and returns the ClickableSpan found under it (if any).
*
* @return The touched ClickableSpan or null.
*/
protected ClickableSpan findClickableSpanUnderTouch(TextView textView, Spannable text, MotionEvent event) {
// So we need to find the location in text where touch was made, regardless of whether the TextView
// has scrollable text. That is, not the entire text is currently visible.
int touchX = (int) event.getX();
int touchY = (int) event.getY();
// Ignore padding.
touchX -= textView.getTotalPaddingLeft();
touchY -= textView.getTotalPaddingTop();
// Account for scrollable text.
touchX += textView.getScrollX();
touchY += textView.getScrollY();
final Layout layout = textView.getLayout();
final int touchedLine = layout.getLineForVertical(touchY);
final int touchOffset = layout.getOffsetForHorizontal(touchedLine, touchX);
touchedLineBounds.left = layout.getLineLeft(touchedLine);
touchedLineBounds.top = layout.getLineTop(touchedLine);
touchedLineBounds.right = layout.getLineWidth(touchedLine) + touchedLineBounds.left;
touchedLineBounds.bottom = layout.getLineBottom(touchedLine);
if (touchedLineBounds.contains(touchX, touchY)) {
// Find a ClickableSpan that lies under the touched area.
final Object[] spans = text.getSpans(touchOffset, touchOffset, ClickableSpan.class);
for (final Object span : spans) {
if (span instanceof ClickableSpan) {
return (ClickableSpan) span;
}
}
// No ClickableSpan found under the touched location.
return null;
} else {
// Touch lies outside the line's horizontal bounds where no spans should exist.
return null;
}
}
/**
* Adds a background color span at <var>clickableSpan</var>'s location.
*/
protected void highlightUrl(TextView textView, ClickableSpan clickableSpan, Spannable text) {
if (isUrlHighlighted) {
return;
}
isUrlHighlighted = true;
int spanStart = text.getSpanStart(clickableSpan);
int spanEnd = text.getSpanEnd(clickableSpan);
BackgroundColorSpan highlightSpan = new BackgroundColorSpan(textView.getHighlightColor());
text.setSpan(highlightSpan, spanStart, spanEnd, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
textView.setTag(R.id.bettermovementmethod_highlight_background_span, highlightSpan);
Selection.setSelection(text, spanStart, spanEnd);
}
/**
* Removes the highlight color under the Url.
*/
protected void removeUrlHighlightColor(TextView textView) {
if (!isUrlHighlighted) {
return;
}
isUrlHighlighted = false;
Spannable text = (Spannable) textView.getText();
BackgroundColorSpan highlightSpan = (BackgroundColorSpan) textView.getTag(R.id.bettermovementmethod_highlight_background_span);
text.removeSpan(highlightSpan);
Selection.removeSelection(text);
}
protected void startTimerForRegisteringLongClick(TextView textView, LongPressTimer.OnTimerReachedListener longClickListener) {
ongoingLongPressTimer = new LongPressTimer();
ongoingLongPressTimer.setOnTimerReachedListener(longClickListener);
textView.postDelayed(ongoingLongPressTimer, ViewConfiguration.getLongPressTimeout());
}
/**
* Remove the long-press detection timer.
*/
protected void removeLongPressCallback(TextView textView) {
if (ongoingLongPressTimer != null) {
textView.removeCallbacks(ongoingLongPressTimer);
ongoingLongPressTimer = null;
}
}
protected void dispatchUrlClick(TextView textView, ClickableSpan clickableSpan) {
ClickableSpanWithText clickableSpanWithText = ClickableSpanWithText.ofSpan(textView, clickableSpan);
boolean handled = onLinkClickListener != null && onLinkClickListener.onClick(textView, clickableSpanWithText);
if (!handled) {
// Let Android handle this click.
clickableSpanWithText.span().onClick(textView);
}
}
protected void dispatchUrlLongClick(TextView textView, ClickableSpan clickableSpan) {
ClickableSpanWithText clickableSpanWithText = ClickableSpanWithText.ofSpan(textView, clickableSpan);
boolean handled = onLinkLongClickListener != null && onLinkLongClickListener.onLongClick(textView, clickableSpanWithText);
if (!handled) {
// Let Android handle this long click as a short-click.
clickableSpanWithText.span().onClick(textView);
}
}
protected static final class LongPressTimer implements Runnable {
private OnTimerReachedListener onTimerReachedListener;
protected interface OnTimerReachedListener {
void onTimerReached();
}
@Override
public void run() {
onTimerReachedListener.onTimerReached();
}
public void setOnTimerReachedListener(OnTimerReachedListener listener) {
onTimerReachedListener = listener;
}
}
/**
* A wrapper to support all {@link ClickableSpan}s that may or may not provide URLs.
*/
protected static class ClickableSpanWithText {
private ClickableSpan span;
private String text;
protected static ClickableSpanWithText ofSpan(TextView textView, ClickableSpan span) {
Spanned s = (Spanned) textView.getText();
String text;
if (span instanceof URLSpan) {
text = ((URLSpan) span).getURL();
} else {
int start = s.getSpanStart(span);
int end = s.getSpanEnd(span);
text = s.subSequence(start, end).toString();
}
return new ClickableSpanWithText(span, text);
}
protected ClickableSpanWithText(ClickableSpan span, String text) {
this.span = span;
this.text = text;
}
protected ClickableSpan span() {
return span;
}
protected String text() {
return text;
}
}
}

View File

@ -141,12 +141,14 @@
android:layout_marginTop="4dp"
android:textAppearance="@style/NavView.TextView.Helper"
android:text="@string/dialog_event_details_topic"/>
<TextView
android:id="@+id/topic"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{event.topic}"
android:textIsSelectable="true"
android:textAppearance="@style/NavView.TextView.Medium"
android:textIsSelectable="true"
tools:text="Rozdział II: Panowanie Piastów i Jagiellonów.Przeniesiony z 11 grudnia." />
<View

View File

@ -141,7 +141,6 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:autoLink="all"
android:minHeight="250dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"

View File

@ -6,4 +6,5 @@
<resources>
<item name="move_card_down_action" type="id"/>
<item name="move_card_up_action" type="id"/>
<item name="bettermovementmethod_highlight_background_span" type="id" />
</resources>