Skip to content

Commit

Permalink
feat: Add AutoLinkText composable
Browse files Browse the repository at this point in the history
  • Loading branch information
kongwoojin committed Jun 8, 2024
1 parent 60b03e9 commit deb06e3
Show file tree
Hide file tree
Showing 4 changed files with 295 additions and 42 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package com.kongjak.koreatechboard.ui.components.text

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import com.kongjak.koreatechboard.constraint.REGEX_EMAIL
import com.kongjak.koreatechboard.constraint.REGEX_HTTP_HTTPS
import com.kongjak.koreatechboard.constraint.REGEX_PHONE_NUMBER

@Composable
fun AutoLinkText(
text: String,
modifier: Modifier = Modifier,
vararg autoLinkType: AutoLinkType = arrayOf(AutoLinkType.ALL),
openWeb: ((webUrl: String) -> Unit?)? = null,
openPhone: ((phoneNumber: String) -> Unit?)? = null,
openEmail: ((email: String) -> Unit?)? = null
) {
if (autoLinkType.isEmpty()) throw IllegalArgumentException("AutoLinkType must not be empty")

var web = false
var phone = false
var email = false

var webOffsets = emptyList<IntRange>()
var phoneOffsets = emptyList<IntRange>()
var emailOffsets = emptyList<IntRange>()

if (autoLinkType.contains(AutoLinkType.ALL)) {
web = true
phone = true
email = true
} else if (autoLinkType.contains(AutoLinkType.WEB)) {
web = true
webOffsets = extractAllURLOffsets(text)
} else if (autoLinkType.contains(AutoLinkType.PHONE)) {
phone = true
phoneOffsets = extractAllPhoneNumberOffsets(text)
} else if (autoLinkType.contains(AutoLinkType.EMAIL)) {
email = true
emailOffsets = extractAllEmailOffsets(text)
}

val annotatedString = buildAnnotatedString {
append(text)
if (web) {
for (offset in webOffsets) {
val urlTag = "${ANNOTATION_URL_PREFIX}${System.currentTimeMillis()}"
addStringAnnotation(
tag = urlTag,
annotation = text.substring(offset.first, offset.last),
start = offset.first,
end = offset.last
)
addStyle(
SpanStyle(
textDecoration = TextDecoration.Underline
),
offset.first,
offset.last
)
}
}

if (phone) {
for (offset in phoneOffsets) {
val phoneNumberTag =
"${ANNOTATION_PHONE_NUMBER_PREFIX}${System.currentTimeMillis()}"
addStringAnnotation(
tag = phoneNumberTag,
annotation = text.substring(offset.first, offset.last),
start = offset.first,
end = offset.last
)
addStyle(
SpanStyle(
textDecoration = TextDecoration.Underline
),
offset.first,
offset.last
)
}
}

if (email) {
for (offset in emailOffsets) {
val emailTag = "${ANNOTATION_EMAIL_PREFIX}${System.currentTimeMillis()}"
addStringAnnotation(
tag = emailTag,
annotation = text.substring(offset.first, offset.last),
start = offset.first,
end = offset.last
)
addStyle(
SpanStyle(
textDecoration = TextDecoration.Underline
),
offset.first,
offset.last
)
}
}
}

CustomClickableText(
modifier = modifier,
text = annotatedString
) { offset ->
annotatedString.getStringAnnotations(offset, offset).firstOrNull()?.let { url ->
if (url.tag.startsWith(ANNOTATION_URL_PREFIX)) {
openWeb?.invoke(url.item)
} else if (url.tag.startsWith(ANNOTATION_PHONE_NUMBER_PREFIX)) {
openPhone?.invoke(url.item)
} else if (url.tag.startsWith(ANNOTATION_EMAIL_PREFIX)) {
openEmail?.invoke(url.item)
}
}
}
}


sealed class AutoLinkType {
data object WEB : AutoLinkType()
data object PHONE : AutoLinkType()
data object EMAIL : AutoLinkType()
data object ALL : AutoLinkType()
}

private fun extractAllURLOffsets(text: String): List<IntRange> {
val urlRegex = Regex(REGEX_HTTP_HTTPS)
val matches = urlRegex.findAll(text)
return matches.map { it.range.first..it.range.last + 1 }.toList()
}

private fun extractAllPhoneNumberOffsets(text: String): List<IntRange> {
val phoneNumberRegex = Regex(REGEX_PHONE_NUMBER)
val matches = phoneNumberRegex.findAll(text)
return matches.map { it.range.first..it.range.last + 1 }.toList()
}

private fun extractAllEmailOffsets(text: String): List<IntRange> {
val emailRegex = Regex(REGEX_EMAIL)
val matches = emailRegex.findAll(text)
return matches.map { it.range.first..it.range.last + 1 }.toList()
}

const val ANNOTATION_URL_PREFIX = "url_"
const val ANNOTATION_PHONE_NUMBER_PREFIX = "phone_number_"
const val ANNOTATION_EMAIL_PREFIX = "email_"
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.kongjak.koreatechboard.ui.components.text

import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit

@Composable
fun CustomClickableText(
text: AnnotatedString,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
inlineContent: Map<String, InlineTextContent> = mapOf(),
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
onClick: (Int) -> Unit
) {
val textColor = color.takeOrElse {
style.color.takeOrElse {
LocalContentColor.current
}
}

val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick) {
detectTapGestures { pos ->
layoutResult.value?.let { layoutResult ->
onClick(layoutResult.getOffsetForPosition(pos))
}
}
}

SelectionContainer {
BasicText(
text = text,
modifier = modifier.then(pressIndicator),
style = style.merge(
color = textColor,
fontSize = fontSize,
fontWeight = fontWeight,
textAlign = textAlign ?: TextAlign.Unspecified,
lineHeight = lineHeight,
fontFamily = fontFamily,
textDecoration = textDecoration,
fontStyle = fontStyle,
letterSpacing = letterSpacing
),
onTextLayout = {
layoutResult.value = it
onTextLayout(it)
},
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
minLines = minLines,
inlineContent = inlineContent
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.kongjak.koreatechboard.ui.components.text

import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import com.kongjak.koreatechboard.domain.model.Files

@Composable
fun FileText(modifier: Modifier = Modifier, files: List<Files>) {
val context = LocalContext.current

val annotatedString = buildAnnotatedString {
for (file in files) {
pushStringAnnotation(file.fileName, file.fileUrl)
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(file.fileName)
}
pop()
append("\n")
}
}

ClickableText(modifier = modifier, text = annotatedString) { offset ->
annotatedString.getStringAnnotations(offset, offset)
.firstOrNull()?.let { url ->
val builder = CustomTabsIntent.Builder()
val customTabsIntent = builder.build()
customTabsIntent.launchUrl(
context,
if (url.item.startsWith("http")) {
Uri.parse(url.item)
} else {
Uri.parse("http://www.koreatech.ac.kr/${url.item}")
}
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,9 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.kongjak.koreatechboard.constraint.REGEX_HTTP_HTTPS
import com.kongjak.koreatechboard.ui.components.text.CustomClickableText
import kotlin.random.Random
import com.kongjak.koreatechboard.ui.components.text.AutoLinkText
import com.kongjak.koreatechboard.ui.components.text.AutoLinkType

@Composable
fun LicenseScreen() {
Expand All @@ -25,47 +21,18 @@ fun LicenseScreen() {
val bytes = it.readBytes()
val text = bytes.decodeToString()

val urlOffsets = extractAllURLOffsets(text)

val annotatedText = buildAnnotatedString {
append(text)
for (offset in urlOffsets) {
val urlTag = "${Random.nextInt(0, 1000)}"
addStringAnnotation(
tag = urlTag,
annotation = text.substring(offset.first, offset.last),
start = offset.first,
end = offset.last
)
addStyle(
SpanStyle(
textDecoration = TextDecoration.Underline
),
offset.first,
offset.last
)
}
}

CustomClickableText(
AutoLinkText(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
text = annotatedText,
onClick = { offset ->
annotatedText.getStringAnnotations(offset, offset).firstOrNull()?.let { url ->
val builder = CustomTabsIntent.Builder()
val customTabsIntent = builder.build()
customTabsIntent.launchUrl(context, Uri.parse(url.item))
}
text = text,
autoLinkType = arrayOf(AutoLinkType.WEB),
openWeb = { url ->
val builder = CustomTabsIntent.Builder()
val customTabsIntent = builder.build()
customTabsIntent.launchUrl(context, Uri.parse(url))
}
)
}
}

private fun extractAllURLOffsets(text: String): List<IntRange> {
val urlRegex = Regex(REGEX_HTTP_HTTPS)
val matches = urlRegex.findAll(text)
return matches.map { it.range.first..it.range.last + 1 }.toList()
}

0 comments on commit deb06e3

Please sign in to comment.