Jetpack Composeでテキストエディター(Beta版)を作ってみた

# はじめに

Jetpack Composeで開発しているアプリのテキストエディターを`BasicTextField`で作成してみたのですが書きごごちがイマイチと感じました。

 <img width=300 src="https://res.craft.do/user/full/3a21bd0e-fe7a-39aa-73ad-b52ef24b655b/doc/A7FDFD47-B4B7-46EE-B4B2-628642C17B3F/D269CE30-91BB-47B1-ABDC-38BDD2563373_2/OfIl7LYdZtyB7ygE7HydnmmY8nSJXEUtNmNcXSOP2Dcz/AnimatedImage.gif">

私は普段はmacOSとiOSでCraftと呼ばれるノートアプリを愛用しています。Craftのテキストエディタは書きごごちが素晴らしく不満点がないのでこれに近いテキストエディタが欲しいなと思いました。

https://www.craft.do/

 <img width=300 src="https://res.craft.do/user/full/3a21bd0e-fe7a-39aa-73ad-b52ef24b655b/doc/A7FDFD47-B4B7-46EE-B4B2-628642C17B3F/74C503F3-9536-495C-8F8E-C988902B8896_2/JwbbND1m0riShiE9hfijbqKBFHKBWLHJVkFi3PJFAZ0z/RPReplay_Final1656133235.gif">

Craftライクのテキストエディタに近づけたものが欲しい、無いのであれば自分で作ろうかと思いtext-editor-composeというライブラリをJetpack Composeで作成してみました。

https://github.com/kaleidot725/text-editor-compose

 <img width=300 src="https://res.craft.do/user/full/3a21bd0e-fe7a-39aa-73ad-b52ef24b655b/doc/A7FDFD47-B4B7-46EE-B4B2-628642C17B3F/394EF408-FB9B-4806-A1B2-741E91FB5546_2/W7keQdNCcx9EZYuZNDQ2TdJXEwJhfsQxmMq0wLoQ5yEz/AnimatedImage.gif">


# できること

## テキスト編集

各行のテキスト編集ができます、以下のような感じでテキストの追加＆削除ができます。

![EMULATOR-2022_06_25_14_57_28.gif](https://res.craft.do/user/full/3a21bd0e-fe7a-39aa-73ad-b52ef24b655b/doc/A7FDFD47-B4B7-46EE-B4B2-628642C17B3F/C8BCCC3C-773C-40E3-9229-53703A5AEBA7_2/yRlzYxke1Lfx3avcYV5r1f4sS41kuCX7nWiTrgxKhWoz/EMULATOR-2022_06_25_14_57_28.gif)

## 改行＆改行削除

改行また改行削除ができます、以下のような感じでテキストの途中での改行＆改行削除もできます。

![AnimatedImage.gif](https://res.craft.do/user/full/3a21bd0e-fe7a-39aa-73ad-b52ef24b655b/doc/A7FDFD47-B4B7-46EE-B4B2-628642C17B3F/F349AD9E-027B-4D25-9198-E2CBEC4DFA82_2/nLYfMwkKuiMHJPJytc38LRgsH12vobVJ9VTMsgbq0bwz/AnimatedImage.gif)

## 行数表示

行数表示のカスタマイズができます。

![AnimatedImage.gif](https://res.craft.do/user/full/3a21bd0e-fe7a-39aa-73ad-b52ef24b655b/doc/A7FDFD47-B4B7-46EE-B4B2-628642C17B3F/09E29C34-B18C-45C0-8350-9A4DD9BC7E55_2/avOnWTuxMzyxQU8euaBKidr4ZSUvIOQ5E8TDO5xVJWwz/AnimatedImage.gif)

## 選択位置表示

行数選択表示のカスタマイズができます。

![AnimatedImage.gif](https://res.craft.do/user/full/3a21bd0e-fe7a-39aa-73ad-b52ef24b655b/doc/A7FDFD47-B4B7-46EE-B4B2-628642C17B3F/ACDFF973-CB61-4D4D-91A9-4601E26C34F9_2/oYx33KTx18CMUuMdS2ZbKwPKv5yZ42aaMqopDr3b0rkz/AnimatedImage.gif)

## 複数行の選択＆コピー＆削除

複数行に対しての選択・コピー・削除ができます。UIに関してはライブラリ側で用意していないので各自でカスタマイズする形になります。

![AnimatedImage.gif](https://res.craft.do/user/full/3a21bd0e-fe7a-39aa-73ad-b52ef24b655b/doc/A7FDFD47-B4B7-46EE-B4B2-628642C17B3F/D70F5BB8-45EF-4DFE-A9E6-796C5BFE6B87_2/x7vZq0SiGdpMidpGSzoZFt68Qco1LWRJzaf74flyijwz/AnimatedImage.gif)

### 複数行を選択してコピーする

![AnimatedImage.gif](https://res.craft.do/user/full/3a21bd0e-fe7a-39aa-73ad-b52ef24b655b/doc/65761F8E-8E02-4465-B09C-03DE03375438/21A0C847-A41C-4A18-980B-076AC54919E8_2/MgMBjJyHGVJfF4zqCshExZvI6e1Py9zSnHJBiErFo6Yz/AnimatedImage.gif)

### 複数行を選択して削除する

![AnimatedImage.gif](https://res.craft.do/user/full/3a21bd0e-fe7a-39aa-73ad-b52ef24b655b/doc/65761F8E-8E02-4465-B09C-03DE03375438/A3F0CFD9-DB23-4849-A529-81D82A3C72EE_2/peP08FeAtNEAqzrBzDPIjo9TeC5kpUiN16N9wjj5nK0z/AnimatedImage.gif)

# まだできないこと


## 物理キーボードでの操作は未対応

物理キーボードでの操作にはまだ対応していないです。基本的な文字の入力はできますが上下矢印キーでの行移動やBackspaceキーでの改行削除などできない操作が色々とあります。Androidタブレット端末だと物理キーボードを接続するケースもあると思うので上下矢印キーやBackspaceキーでの操作する機能の追加を予定中です。

![AnimatedImage.gif](https://res.craft.do/user/full/3a21bd0e-fe7a-39aa-73ad-b52ef24b655b/doc/A7FDFD47-B4B7-46EE-B4B2-628642C17B3F/4308C599-3FC2-4E4D-BF99-6D544AE33382_2/DVN4y9jxRRDdjhHE1yixqV2t7xGEALgf5dzqQ7M7rokz/AnimatedImage.gif)

# 使い方

ライブラリの使い方は簡単です。
以下の手順で依存関係を追加して、複数行コードを記載するだけで簡単に使えます。

## ステップ1: JitPack リポジトリをbuild.gradleに追加する

```groovy
allprojects {
	repositories {
		...
		maven { url 'https://jitpack.io' }
	}
}
```

## ステップ2: ライブラリを依存関係に追加する

```groovy
dependencies {
	implementation 'com.github.kaleidot725:text-editor-compose:0.1.0'
}
```

## ステップ3: TextEditor＆TextEditorStateを宣言する

```kotlin
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            SampleTheme {
                val textEditorState by rememberTextEditorState(lines = DemoText.lines())
                TextEditor(
                    textEditorState = textEditorState, // TextEditorの状態
                    onUpdatedState = { },              // TextEditorの状態更新時によばれるラムダ関数
                    modifier = Modifier.fillMaxSize()  // TextEditorのModifier 
                )
            }
        }
    }
}
```

## ステップ4: 各行の表示内容をカスタマイズする

```kotlin
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            SampleTheme {
                val textEditorState by rememberTextEditorState(lines = DemoText.lines())
                TextEditor(
                    textEditorState = textEditorState,
                    onUpdatedState = { },
                    modifier = Modifier.fillMaxSize(),
                    // index : 行数を表す識別子（1行目はindexが0でN行目はindexがn-1となる）
                    // isSelected : 各行の選択状態
                    // innerTextField : 各行の必要なComposable関数
                    decorationBox = { index, isSelected, innerTextField ->
                        // 行表示の他に行数表示＆選択状態表示を追加する
                        val backgroundColor = if (isSelected) Color(0x8000ff00) else Color.White           
                        Row(modifier = Modifier.background(backgroundColor)) {
                            Text(text = (index + 1).toString().padStart(3, '0'))
                            Spacer(modifier = Modifier.width(4.dp))
                            innerTextField(modifier = Modifier.fillMaxWidth())
                        }
                    }
                )
            }
        }
    }
}
```

## ステップ5: 複数行選択メニューをカスタマイズする

```kotlin
```kotlin
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            SampleTheme {
                val textEditorState by rememberTextEditorState(lines = DemoText.lines())
                TextEditor(
                    textEditorState = textEditorState,
                    onUpdatedState = { },
                    modifier = Modifier.fillMaxSize(),
                    decorationBox = { index, isSelected, innerTextField ->
                        val backgroundColor = if (isSelected) Color(0x8000ff00) else Color.White
                        Row(modifier = Modifier.background(backgroundColor)) {
                            Text(text = (index + 1).toString().padStart(3, '0'))
                            Spacer(modifier = Modifier.width(4.dp))
                            innerTextField(modifier = Modifier.fillMaxWidth())
                        }
                    }
                )
            }
        }
    }
}

@Composable
private fun ColumnScope.TextEditorMenu(textEditorState: TextEditorState) {
    val context: Context = LocalContext.current
    val clipboardManager: ClipboardManager = LocalClipboardManager.current

    Row(modifier = Modifier.padding(8.dp)) {
        Text(
            text = "Enable multiple selection mode",
            modifier = Modifier
                .weight(0.9f, true)
                .align(Alignment.CenterVertically)
        )
        Switch(
            checked = textEditorState.isMultipleSelectionMode.value,
            onCheckedChange = {
                textEditorState.enableMultipleSelectionMode(
                    !textEditorState.isMultipleSelectionMode.value
                )
            }
        )
    }

    Row(modifier = Modifier.padding(8.dp)) {
        Text(
            text = "Copy selected lines",
            modifier = Modifier
                .weight(0.9f, true)
                .align(Alignment.CenterVertically)
        )
        Button(
            onClick = {
                val text = textEditorState.getSelectedText()
                textEditorState.enableMultipleSelectionMode(false)

                clipboardManager.setText(AnnotatedString(text))
                Toast.makeText(context, "Copy selected text to clipboard", Toast.LENGTH_SHORT).show()
            }
        ) {
            Text(text = "EXECUTE")
        }
    }
    
    Row(modifier = Modifier.padding(8.dp)) {
        Text(
            text = "Delete selected lines",
            modifier = Modifier
                .weight(0.9f, true)
                .align(Alignment.CenterVertically)
        )
        Button(onClick = {
            textEditorState.deleteSelectedLines()
            textEditorState.enableMultipleSelectionMode(false)
        }) {
            Text(text = "EXECUTE")
        }
    }
}
```

# おわりに

プログラミングには欠かせないテキストエディターですが自分で実装するとなると以外と難しいところがあったり、普段は使っているけども仕様として認識できていないところがあったりと勉強になりました。

text-editor-composeと並行してノートアプリを開発しているのですが、ノートアプリの進化にあわせてtext-editor-composeも成長させていたけたらなと思っています。

text-editor-composeは以下のGitHubレポジトリで開発中なので、興味がある方はスター登録＆コントリビュートしていただればと思います。

https://github.com/kaleidot725/text-editor-compose