2021/06/26
[Jetpack Compose]プログラミング言語のシンタックスハイライト
概要
以下のような見た目のComposableを実装しました。
- Input
- 言語の種類(javaとかgoとかの文字列)
- ソースコード文字列
- Output
- スクロール可能(縦横どちらも可能)なビュー
- ソースコードはシンタックスハイライトされている
- コピーボタンが右上隅に配置されている
実装の基本方針
以下のライブラリをうまく利用させてもらいました。
- こちらのライブラリはJetpack Compose用にできていないため、
CodeHighlighter.highlight
の戻り値をandroidx.compose.ui.text.AnnotatedString
になるように改造しました。 - その後、シンタックスハイライトされた
AnnotatedString
を使って、rememberScrollState
をうまく利用して横にも縦にもスクロール可能なコードビューを実装しました - 最後に、
androidx.compose.ui.platform.LocalClipboardManager
を使って、コピーボタンを押したらソースコードがクリップボードにコピーされるボタンを配置しました
実装の詳細
CodeHighlighterの改造
以下のようにしました。
AnnotatedString.Builder
をうまく使い、ソースコードのパース結果をハイライトしながら追加していきます。
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import io.github.kbiakov.codeview.highlight.parser.ParseResult
import io.github.kbiakov.codeview.highlight.prettify.PrettifyParser
import java.util.*
object CodeHighlighter {
/**
* Highlight code content.
*
* @param language Programming language
* @param source Source code as single string
* @param theme Color theme (see below)
* @return Highlighted code in form of AnnotatedString
*/
fun highlight(language: String, source: String, theme: ColorThemeData): AnnotatedString {
val colors = buildColorsMap(theme)
return with (AnnotatedString.Builder()) {
PrettifyParser().parse(language, source).forEach { parseResult ->
colors[parseResult]?.let {
pushStyle(SpanStyle(color = it))
}
append(source highlight parseResult)
colors[parseResult]?.let {
pop()
}
}
toAnnotatedString()
}
}
// - Helpers
/**
* Parse input by extracting highlighted content.
*
* @param result Syntax unit
* @return Content to highlight
*/
private infix fun String.highlight(result: ParseResult) = safeLT {
substring(result.offset, result.offset + result.length)
}
/**
* Color accessor from built color map for selected color theme.
*
* @param result Syntax unit
* @return Color for syntax unit
*/
private operator fun HashMap<String, Color>.get(result: ParseResult) =
this[result.styleKeys[0]] ?: this["pln"]
/**
* Build fast accessor (as map) for selected color theme.
*
* @param colorTheme Color theme
* @return Colors map built from color theme
*/
private fun buildColorsMap(theme: ColorThemeData): HashMap<String, Color> {
fun color(body: SyntaxColors.() -> Int) =
theme.syntaxColors.let { body(it).color() }
return hashMapOf(
"typ" to color { type },
"kwd" to color { keyword },
"lit" to color { literal },
"com" to color { comment },
"str" to color { string },
"pun" to color { punctuation },
"pln" to color { plain },
"tag" to color { tag },
"dec" to color { declaration },
"src" to color { plain },
"atn" to color { attrName },
"atv" to color { attrValue },
"nocode" to color { plain })
}
// - Escaping/extracting "less then" symbol
private fun String.safeLT(op: String.() -> String) = escapeLT().op().expandLT()
private fun String.escapeLT() = replace("<", "^")
private fun String.expandLT() = replace("^", "<")
}
/**
* Color theme presets.
*/
enum class ColorTheme(
val syntaxColors: SyntaxColors = SyntaxColors(),
val numColor: Int,
val bgContent: Int,
val bgNum: Int,
val noteColor: Int) {
SOLARIZED_LIGHT(
numColor = 0x93A1A1,
bgContent = 0xFDF6E3,
bgNum = 0xEEE8D5,
noteColor = 0x657B83),
MONOKAI(
syntaxColors = SyntaxColors(
type = 0xA7E22E,
keyword = 0xFA2772,
literal = 0x66D9EE,
comment = 0x76715E,
string = 0xE6DB74,
punctuation = 0xC1C1C1,
plain = 0xF8F8F0,
tag = 0xF92672,
declaration = 0xFA2772,
attrName = 0xA6E22E,
attrValue = 0xE6DB74),
numColor = 0x48483E,
bgContent = 0x272822,
bgNum = 0x272822,
noteColor = 0xCFD0C2),
DEFAULT(
numColor = 0x99A8B7,
bgContent = 0xE9EDF4,
bgNum = 0xF2F2F6,
noteColor = 0x4C5D6E);
fun theme() = ColorThemeData(
syntaxColors,
numColor,
bgContent,
bgNum,
noteColor)
}
/**
* Custom color theme.
*/
data class ColorThemeData(
val syntaxColors: SyntaxColors = SyntaxColors(),
val numColor: Int,
val bgContent: Int,
val bgNum: Int,
val noteColor: Int) {
/**
* Decompose preset color theme to data.
* Use this form for using from Kotlin.
*/
fun with(
mySyntaxColors: SyntaxColors = syntaxColors,
myNumColor: Int = numColor,
myBgContent: Int = bgContent,
myBgNum: Int = bgNum,
myNoteColor: Int = noteColor
) = this
/**
* Decompose preset color theme to data.
* Use this form for using from Java.
*/
fun withSyntaxColors(mySyntaxColors: SyntaxColors) =
with(mySyntaxColors = mySyntaxColors)
fun withNumColor(myNumColor: Int) =
with(myNumColor = myNumColor)
fun withBgContent(myBgContent: Int) =
with(myBgContent = myBgContent)
fun withBgNum(myBgNum: Int) =
with(myBgNum = myBgNum)
fun withNoteColor(myNoteColor: Int) =
with(myNoteColor = myNoteColor)
}
/**
* Colors for highlighting code units.
*/
data class SyntaxColors(
val type: Int = 0x859900,
val keyword: Int = 0x268BD2,
val literal: Int = 0x269186,
val comment: Int = 0x93A1A1,
val string: Int = 0x269186,
val punctuation: Int = 0x586E75,
val plain: Int = 0x586E75,
val tag: Int = 0x859900,
val declaration: Int = 0x268BD2,
val attrName: Int = 0x268BD2,
val attrValue: Int = 0x269186)
/**
* Font presets.
*/
enum class Font {
Consolas,
CourierNew,
DejaVuSansMono,
DroidSansMonoSlashed,
Inconsolata,
Monaco;
companion object {
val Default = DroidSansMonoSlashed
}
}
// - Helpers
/**
* @return Converted hex int to color by adding alpha-channel
*/
fun Int.color(): Color = try {
Color(0xff000000 + this)
} catch (e: IllegalArgumentException) {
Color.White.copy(alpha = 0.0f)
}
CodeViewの実装
上記で改造したCodeHighlighter
をうまく組み合わせて以下のようにしました。
@Composable
fun CodeView(
language: String,
source: String,
modifier: Modifier = Modifier,
) {
val horizontalScroll = rememberScrollState(0)
val verticalScroll = rememberScrollState(0)
val context = CodeHighlighter.highlight(
language = language,
source = source,
theme = ColorTheme.MONOKAI.theme(),
)
Box(
modifier = modifier.fillMaxWidth(),
) {
Surface(
color = Gray900,
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 12.dp,
)
.horizontalScroll(horizontalScroll)
.verticalScroll(verticalScroll)
) {
Text(
text = context
)
}
}
}
}
クリップボードへコピーするボタンの追加
@Composable
fun CodeView(
language: String,
source: String,
modifier: Modifier = Modifier,
) {
val horizontalScroll = rememberScrollState(0)
val verticalScroll = rememberScrollState(0)
val context = CodeHighlighter.highlight(
language = language,
source = source,
theme = ColorTheme.MONOKAI.theme(),
)
val clipboardManager = LocalClipboardManager.current // この行を追加
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.TopEnd, // この行を追加
) {
Surface(
color = Gray900,
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 12.dp,
)
.horizontalScroll(horizontalScroll)
.verticalScroll(verticalScroll)
) {
Text(
text = context
)
}
}
// 以下のblockを追加
CopyButton() {
clipboardManager.setText(AnnotatedString(source))
}
}
}
// Copyボタンのコンポーネントを追加
@Composable
private fun CopyButton(upPress: () -> Unit) {
IconButton(
onClick = upPress,
modifier = Modifier
.padding(horizontal = 10.dp, vertical = 10.dp)
.size(28.dp)
.background(
color = Gray900.copy(alpha = 0.32f),
shape = CircleShape
)
) {
Icon(
painterResource(id = R.drawable.ic_clipboard_copy_outline), // heroiconのsvgをresに追加して利用
tint = White,
contentDescription = "copy",
modifier = Modifier.size(20.dp)
)
}
}
Preview
以下のPreviewで確認しながら実装しました。
@Preview("code view default")
@Composable
fun CodeViewPreview() {
CodeView(
language = "java",
source = """
private val DotSize = 18.dp
@Composable
fun Loader() {
val delayUnit = 300
val infiniteTransition = rememberInfiniteTransition()
@Composable
fun animateScaleWithDelay(delay: Int) = infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = delayUnit * 4
0f at delay with LinearEasing
1f at delay + delayUnit with LinearEasing
0f at delay + delayUnit * 2
}
)
)
val scale1 by animateScaleWithDelay(0)
val scale2 by animateScaleWithDelay(delayUnit)
val scale3 by animateScaleWithDelay(delayUnit * 2)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
val space = 2.dp
Dot(scale1)
Spacer(Modifier.width(space))
Dot(scale2)
Spacer(Modifier.width(space))
Dot(scale3)
}
}
@Composable
private fun Dot(scale: Float) = Spacer(
modifier = Modifier
.size(DotSize)
.scale(scale = scale)
.background(color = MyTheme.colors.active, shape = CircleShape)
)
""".trimIndent()
)
}
結構綺麗な見た目になって満足しました。
以上です。
関連する記事
UI ComponentにJetpackComposeを採用したアプリを開発してみました
Jetpack Composeメインのアプリ開発を行いました。アプリ内で紹介しているAvatar Componentの開発方法を紹介します。
[Jetpack Compose]プログラミング言語のシンタックスハイライト
github.com/kbiakov/CodeView-Androidを少し改造して、Jetpack Composeでコードビューを実装してみました
[Jetpack Compose]Analytics / Crashlytics / FCMを導入
FirebaseをJetpack Composeアプリに導入して、プッシュ通知を受取り対応する画面を開くようにしました。またAnalyticsやCrashlyticsも同時に導入しました。
[Jetpack Compose]ローディングアニメーションつきカードコンポーネントの作成
rememberinfinitetransitionをうまく活用し、Jetpack Composeでローディングアニメーションつきのカードコンポーネントを作成しました。