forked from github/szkolny
[UI] Add context menus in messages and events to quickly run an action.
This commit is contained in:
parent
a082d95b04
commit
c95bc656ea
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
20
app/src/main/java/pl/szczodrzynski/edziennik/Binding.kt
Normal file
20
app/src/main/java/pl/szczodrzynski/edziennik/Binding.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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)
|
||||
|
200
app/src/main/java/pl/szczodrzynski/edziennik/utils/BetterLink.kt
Normal file
200
app/src/main/java/pl/szczodrzynski/edziennik/utils/BetterLink.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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"
|
||||
@ -359,4 +358,4 @@
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
</layout>
|
||||
</layout>
|
||||
|
@ -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>
|
Loading…
x
Reference in New Issue
Block a user