【2022-08-14 更新】Jetpack Compose で Todo アプリを作ってみた


Jetpack Compose は革命かもしれません。

![Reflector Recording.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/588914/c6166770-b97c-a7d5-af6f-d6ed56140d33.gif)

# Composable

View に関わるファイルはこれだけ。

RecyclerView, RecyclerViewAdapter, ViewHolder など不要です。

```kotlin
@Composable
fun TodoScreen(
  viewModel: TodoViewModel = hiltViewModel()
) {

  val items by viewModel.items.collectAsStateWithLifecycle(initialValue = emptyList())
  val target by viewModel.target

  val focusManager = LocalFocusManager.current
  val focusRequester = remember { FocusRequester() }

  val scope: CoroutineScope = rememberCoroutineScope()
  val listState = rememberLazyListState()

  val sheetState = rememberModalBottomSheetState(
    initialValue = ModalBottomSheetValue.Hidden,
    confirmStateChange = {
      focusManager.clearFocus()
      true
    }
  )

  val onShowSheet = {
    scope.launch { sheetState.show() }
    focusRequester.requestFocus()
  }

  val onHideSheet = { onScrollToTop: Boolean ->
    focusManager.clearFocus()
    scope.launch {
      sheetState.hide()
      if (onScrollToTop) listState.animateScrollToItem(index = 0)
    }
  }

  Column {
    ModalBottomSheetLayout(
      sheetState = sheetState,
      sheetContent = {
        Row(
          modifier = Modifier
            .fillMaxWidth()
            .height(IntrinsicSize.Min)
            .padding(8.dp),
          horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
        ) {

          OutlinedTextField(
            value = target.textFieldValue,
            onValueChange = viewModel::updateTarget,
            modifier = Modifier
              .weight(1F)
              .focusRequester(focusRequester),
            keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
            keyboardActions = KeyboardActions(onDone = { onHideSheet(false) }),
            trailingIcon = {
              IconButton(
                onClick = viewModel::clearTarget,
              ) {
                Icon(Icons.Filled.Clear, null)
              }
            }
          )

          if (target.isAdd()) {

            Button( // add
              onClick = {
                viewModel.insertTodo()
                onHideSheet(true)
              },
              modifier = Modifier.size(56.dp)
            ) {
              Icon(Icons.Filled.Add, null)
            }

          } else {

            Button( // update
              onClick = {
                viewModel.updateTodo()
                onHideSheet(true)
              },
              modifier = Modifier.size(56.dp)
            ) {
              Icon(Icons.Filled.Refresh, null)
            }

            Button( // delete
              onClick = {
                viewModel.deleteTodo()
                onHideSheet(false)
              },
              modifier = Modifier.size(56.dp)
            ) {
              Icon(Icons.Filled.Delete, null)
            }

          }
        }
      }
    ) {

      Box {
        LazyColumn(
          modifier = Modifier.fillMaxSize(),
          state = listState,
          verticalArrangement = Arrangement.spacedBy(4.dp),
          contentPadding = PaddingValues(4.dp)
        ) {
          items(
            items = items,
            key = { item -> item.id }
          ) { item ->

            Card(
              shape = RoundedCornerShape(4.dp),
              elevation = 2.dp,
            ) {
              Column(
                modifier = Modifier
                  .animateItemPlacement()
                  .fillMaxWidth()
                  .clickable {
                    viewModel.setTarget(item.id, item.text)
                    onShowSheet()
                  }
                  .padding(horizontal = 16.dp, vertical = 8.dp),
              ) {
                Text(
                  text = item.text,
                  style = MaterialTheme.typography.h5,
                )
                Text(
                  text = item.updated.simpleFormat(),
                  style = MaterialTheme.typography.subtitle1
                )
              }
            }

          }
        }

        FloatingActionButton(
          onClick = {
            viewModel.newTarget()
            onShowSheet()
          },
          modifier = Modifier
            .align(Alignment.BottomEnd)
            .padding(12.dp)
        ) {
          Icon(Icons.Filled.Add, null)
        }

      }
    }
  }
}
```

編集中テキストの <code>State</code> は ViewModel に持たせています。
![スクリーンショット 2022-07-26 14.24.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/588914/9ed9c43f-582d-3f0e-918c-01ea7a921f53.png)
できるだけ <code>State</code> は ViewModel に持っていくほうがいいのではないか、ということを考えています。

パフォーマンスに任せてコードの見通しを優先するほうが「recompose沼」にはまらなくていいのではという逆説。

# Repository

[👉 KMM や マルチプラットフォーム を見据えて SQLDelight で Repository](https://android.benigumo.com/20220808/sqldelight-repository/)

テーブル定義、実行するメソッドに対するクエリーを Todo.sq として配置します。

```sql
-- app/src/main/sqldelight/com/your/package/data/Todo.sq

CREATE TABLE todo (
  id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
  text TEXT NOT NULL,
  updated INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);

INSERT INTO todo(text) VALUES ('宿題をする');
INSERT INTO todo(text) VALUES ('マンガを読む');
INSERT INTO todo(text) VALUES ('プールに行く');

selectAll:
SELECT * FROM todo ORDER BY updated DESC;

deleteAll:
DELETE FROM todo;

insert:
INSERT INTO todo (text) VALUES (:text);

update:
UPDATE todo SET text = :text, updated = (strftime('%s', 'now')) WHERE id = :entryId;

delete:
DELETE FROM todo
WHERE id = :entryId;

count:
SELECT COUNT(id) FROM todo;
```

Hilt 定形で使えます、DatabaseModule。

```kotlin
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

  @Provides
  @Singleton
  fun provideDatabase(@ApplicationContext context: Context): Database {
    val driver = AndroidSqliteDriver(Database.Schema, context, DB_NAME)
    return Database(driver)
  }

  private const val DB_NAME = "database.db"
}
```

ここで、@Singleton としておくことを忘れてはなりません。



時間のかかる処理は Flow を使っておきます。

```kotlin
class TodoRepository @Inject constructor(
  private val database: Database
) {

  fun load(): Flow<List<Todo>> {
    return database.todoQueries.selectAll().asFlow().mapToList(Dispatchers.IO)
  }

  fun count(): Flow<Long> {
    return database.todoQueries.count().asFlow().mapToOne(Dispatchers.IO)
  }

  fun insert(text: String) {
    database.todoQueries.insert(text)
  }

  fun update(id: Long, text: String) {
    database.todoQueries.update(text, id)
  }

  fun delete(id: Long) {
    database.todoQueries.delete(id)
  }

}
```


[(つづく...)](https://qiita.com/chanzmao)

[👉 KMM や マルチプラットフォーム を見据えて SQLDelight で Repository](https://android.benigumo.com/20220808/sqldelight-repository/)
[👉 「Compose Compiler Reports」 recompose される条件とタイミングと範囲を知りたい](https://android.benigumo.com/20220719/recompose/)
[👉 TextField の フォーカス と IME 開閉 と カーソル位置](https://android.benigumo.com/20220628/jetpackcompose-textfield-focus-ime/)
[👉 NavBackStackEntry - Composable のスタックとライフサイクル](https://qiita.com/chanzmao/items/4927676e24fb51c9e777)

[👉 Android ファショ通](https://android.benigumo.com/)