Androidアプリ開発の覚書き

Androidアプリ開発を、Kotlin, Jetpack Composeで行っています。気になったことをChatGPTに聞いて記事にしています。

Excelのような編集可能な表を表示する(Jetpack Compose)

LazyColumn, LazyRow, TextFieldといった基本コンポーネントを並べて表示するというごくごく標準的な方法を教わりました。

ただ、その中で、数値のフォーマットや、列幅の調整等の実装を記述する必要があり、結果的にJetpack Composeでの基本的なコンポーネントの管理方法を知ることができ、大変有益でした。

例えば、コンポーザブル階層を縦断するデータを管理するには、Composition Localという仕組みで定義された値を操作することなどです。会話中で挙げていただいたComposition Localのリストは以下です。

・LocalContentColor
・LocalContentAlpha
・LocalDensity
・LocalFontFamily
・LocalFontSize
・LocalTextInputService
・LocalLifecycleOwner
・LocalContext

Composition Localは、UIを関数で表示するJetpack Composeフレームワークにおいて、上下階層間で共有するデータを保持する仕組みであり、これらを利用して、セル < 行 < 列 < 表の階層を跨ぐ同種のTextFieldのプロパティを管理するわけです。

あと、編集開始時、編集終了時のイベントを捕捉して、編集時はフォーマットなしで、編集しない時はフォーマットありで値を表示するサンプルもかなり有益でした。

以上はUIを従来のXMLでなく、Jetpack Composeのコンポーザブル関数の階層で保持する方法に伴う手法ですが、僕にはかなり難しく思えました。早く慣れたいです。

AndroidのJetpack Composeで開発をしています。画面よりは少し大きめの数表をスクロールして画面に表示させたいです。エクセルのように、値を編集もしたいです。

User
AndroidのJetpack Composeで開発をしています。

画面よりは少し大きめの数表をスクロールして画面に表示させたいです。エクセルのように、値を編集もしたいです。

どのようなコンポーネントを用いるのが適切でしょうか?

ChatGPT
AndroidのJetpack Composeを使用して、エクセルのように値を編集できるスクロール可能な表を実装するには、以下のステップに従うと良いでしょう。

LazyColumn と LazyRow を組み合わせる:
LazyColumn と LazyRow を組み合わせて、行と列がスクロール可能な表を作成します。これにより、ユーザーが大きなデータセットを縦横にスクロールできるようになります。

TextField を使用してセルを編集可能にする:
各セルに TextField を配置して、ユーザーが値を直接編集できるようにします。TextField は、ユーザーがテキストを入力するたびに更新されるように設定します。

データ管理:
表のデータを管理するために、状態管理を適切に行う必要があります。例えば、mutableStateListOf などを使用して、表の各セルの値を保持し、更新します。

セルのサイズとスタイル設定:
各セルのサイズやスタイルは、Modifier を使用してカスタマイズできます。例えば、セルの大きさや色、フォントなどを調整できます。

パフォーマンスの最適化:
大きな表を扱う場合、パフォーマンスを考慮することが重要です。不要なリコンポジションを避けるために、適切なスコープで状態を管理し、変更が必要な部分のみを更新するようにします。

こちらが基本的なコードの例です:

@Composable  
fun EditableTable(data: MutableList<MutableList<String>>) {  
  LazyColumn {  
    items(data.size) { rowIndex ->  
      LazyRow {  
        items(data[rowIndex].size) { columnIndex ->  
          var text by remember { mutableStateOf(data[rowIndex][columnIndex]) }  
          TextField(  
            value = text,  
            onValueChange = { newText ->  
              data[rowIndex][columnIndex] = newText  
              text = newText  
            },  
            modifier = Modifier.width(150.dp).padding(4.dp)  
          )  
        }  
      }  
    }  
  }  
}  

このコードは、表の各行に LazyRow を使用し、各セルに TextField を配置しています。各 TextField は、対応するデータセットを更新するように設定されています。

基本的なコンポーネント組み合わせて作れば良かったのですね。行や列の幅をユーザー側で変更できるようにするにはどうすればよいでしょうか?

User
ありがとうございます。

基本的なコンポーネント組み合わせて作れば良かったのですね。

行や列の幅をユーザー側で変更できるようにするにはどうすればよいでしょうか?

ChatGPT
行や列の幅をユーザーが動的に変更できるようにするには、少し複雑なロジックを追加する必要があります。以下にその方法を説明します。

ドラッグ可能なディバイダーの実装:
ユーザーが列の境界をドラッグして幅を調整できるように、ドラッグ可能なディバイダー(分割線)を実装します。これは、pointerInput と detectDragGestures を使って、ドラッグ操作を検出し、対応する列の幅を更新することで実現します。

状態の保存:
各列の幅を保存するための状態を管理します。通常、mutableStateListOf などを使用して、各列の幅を保持し、更新します。

UIの更新:
列の幅が更新されると、UIもそれに応じて更新されるようにします。これは LazyRow 内の item の Modifier.width を動的に変更することで実現できます。

こちらがサンプルのコードスニペットです:

@Composable  
fun AdjustableTable(data: MutableList<MutableList<String>>) {  
  val columnWidths = remember { mutableStateListOf(*Array(data[0].size) { 100.dp }) }  
  
  LazyColumn {  
    items(data.size) { rowIndex ->  
      LazyRow {  
        itemsIndexed(data[rowIndex]) { columnIndex, cellData ->  
          var text by remember { mutableStateOf(cellData) }  
          TextField(  
            value = text,  
            onValueChange = { newText ->  
              data[rowIndex][columnIndex] = newText  
              text = newText  
            },  
            modifier = Modifier  
              .width(columnWidths[columnIndex])  
              .padding(4.dp)  
          )  
  
          if (columnIndex < data[rowIndex].size - 1) {  
            DraggableDivider(columnWidths, columnIndex)  
          }  
        }  
      }  
    }  
  }  
}  
  
@Composable  
fun DraggableDivider(widths: MutableList<Dp>, index: Int) {  
  var offset by remember { mutableStateOf(0f) }  
  Box(  
    modifier = Modifier  
      .fillMaxHeight()  
      .width(12.dp)  
      .background(Color.Gray)  
      .pointerInput(Unit) {  
        detectDragGestures { change, dragAmount ->  
          offset += dragAmount.x  
          val newWidth = (widths[index].toPx() + offset).coerceAtLeast(50f).dp  
          widths[index] = newWidth  
          offset = 0f  
        }  
      }  
  )  
}  

このコードでは、各列の末尾にグレーのドラッグ可能なディバイダーを配置しています。ユーザーがこのディバイダーをドラッグすると、対応する列の幅が更新されます。幅は最小値を設定して、あまりにも小さくならないように制約を加えています。

Dpオブジェクトとは何でしょうか? toPx() は何をしているのですか?

User
Dpオブジェクトとは何でしょうか?

toPx() は何をしているのですか?

ChatGPT
Android Jetpack Composeでは、UIのサイズや間隔を指定する際によく使用される単位がDp(Density-independent Pixels)です。Dpは、画面の密度に依存しないピクセルで、異なる画面密度のデバイス間で一貫した視覚的サイズを提供するために使用されます。1dpは物理的な画面の密度が160dpi(dots per inch)の場合の1ピクセルに相当します。これにより、異なる解像度や画面サイズを持つデバイスでも、UI要素のサイズが一貫性を持つように設計することが可能です。

toPx()関数は、Dpオブジェクトをピクセル単位に変換するメソッドです。これは特に、ピクセル単位で精密な位置指定やサイズ調整を行う必要がある場合に便利です。Dpからピクセルへの変換は、デバイスの画面密度(DPI)を考慮して行われます。例えば、高密度のデバイスでは1dpが1ピクセルよりも多くのピクセルに相当することがあります。

簡単に言うと、Dpはデバイス間での一貫性を保つための単位で、toPx()はそのDp単位をそのデバイスの実際のピクセル数に変換するためのメソッドです。これにより、開発者は様々なデバイスで一貫したユーザー体験を提供できます。

ありがとうございます。計算はピクセルを表す数値で行い、値はDpオブジェクトで保持しているのですね。

User
ありがとうございます。

計算はピクセルを表す数値で行い、値はDpオブジェクトで保持しているのですね。

ChatGPT
はい、その通りです。Androidの開発においては、ユーザーインターフェースの寸法をDp(Density-independent Pixels)で定義し、内部的な計算や動的な変更が必要な場合にはピクセル値に変換して使用します。これにより、さまざまな画面密度のデバイスでもアプリケーションの見た目が一貫して保たれるため、開発者がデザインをより制御しやすくなります。Dpを使用することで、デバイスに依存しない柔軟なデザインが可能となり、アプリケーションのポータビリティが向上します。

エクセルのように、数値の表示形式を変更できるようにするにはどうすればよいでしょうか?

User
エクセルのように、数値の表示形式を変更できるようにするにはどうすればよいでしょうか?

小数点以下の桁数とか、千単位での','とか、セル中の配置なども調整できるようにしたいです。

ChatGPT
Jetpack Compose でエクセルのような数値の表示形式をカスタマイズするには、主に以下のステップで実装できます:

数値のフォーマット:
数値のフォーマットには、Kotlin の String.format() や DecimalFormat クラスを使用します。これにより、小数点以下の桁数を制御したり、千単位でカンマを挿入するなどのカスタマイズが可能です。

セルのテキスト配置:
TextField コンポーネント内でテキストの配置を調整するには、textAlign プロパティを使用します。これにより、テキストを左寄せ、中央寄せ、または右寄せに設定できます。

表示更新のためのコンポーネント:
データの表示形式が動的に変わるような場合、状態管理を適切に行い、UIがその変更を反映するようにします。
以下に、これらの要素を含むサンプルコードを示します:

import androidx.compose.foundation.text.BasicTextField  
import androidx.compose.ui.text.input.TextFieldValue  
import java.text.DecimalFormat  
  
@Composable  
fun FormattedEditableTable(data: MutableList<MutableList<Double>>) {  
  val decimalFormat = DecimalFormat("#,###.00") // 千単位のカンマと小数点以下2桁を設定  
  
  LazyColumn {  
    items(data.size) { rowIndex ->  
      LazyRow {  
        itemsIndexed(data[rowIndex]) { columnIndex, cellValue ->  
          var text by remember { mutableStateOf(TextFieldValue(decimalFormat.format(cellValue))) }  
  
          BasicTextField(  
            value = text,  
            onValueChange = { newText ->  
              // 数値の再フォーマットと状態の更新  
              text = newText.copy(text = decimalFormat.format(newText.text.toDoubleOrNull() ?: 0.0))  
              data[rowIndex][columnIndex] = newText.text.toDoubleOrNull() ?: 0.0  
            },  
            textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Right), // テキストを右寄せ  
            singleLine = true,  
            decorationBox = @Composable { innerTextField ->  
              Box(  
                contentAlignment = Alignment.CenterEnd, // コンテンツを右寄せに  
                modifier = Modifier.padding(16.dp)  
              ) {  
                innerTextField()  
              }  
            }  
          )  
        }  
      }  
    }  
  }  
}  

このコードでは、BasicTextField を使用して、ユーザーが入力する数値を適切な形式で表示し、更新できるようにしています。また、セル内のテキストは右寄せに設定されています。フォーマットの例として、DecimalFormat を使用して、数値を千単位でカンマを入れ、小数点以下二桁で表示するようにしています。

このコードでは、既存の値を編集時には、フォーマットなしの文字列に変わりますか?

User
このコードでは、既存の値を編集時には、フォーマットなしの文字列に変わりますか?

ChatGPT
このコードでは、実際には既存の値が編集される際に、ユーザーが数字を入力すると、その数字がフォーマットされずに表示される問題があります。ユーザーが数字を入力する度に、その数値を適切なフォーマットで表示するように更新する必要があります。

この問題を解決するには、テキストが更新された際にその内容を適切にフォーマットしてから表示するように処理を追加することが求められます。BasicTextField の onValueChange イベントで、ユーザーの入力を受け取り、その入力を適切にフォーマットしてから状態を更新するようにします。ただし、編集中にリアルタイムでフォーマットを適用すると入力操作が不自然に感じられることがあるため、入力が完了したタイミングでフォーマットを適用するという方法も考えられます。

以下は、入力完了時にのみフォーマットを適用するアプローチを取るサンプルコードです:

@Composable  
fun FormattedEditableTable(data: MutableList<MutableList<Double>>) {  
  val decimalFormat = DecimalFormat("#,###.00") // 千単位のカンマと小数点以下2桁を設定  
  
  LazyColumn {  
    items(data.size) { rowIndex ->  
      LazyRow {  
        itemsIndexed(data[rowIndex]) { columnIndex, cellValue ->  
          var text by remember { mutableStateOf(TextFieldValue(decimalFormat.format(cellValue))) }  
  
          BasicTextField(  
            value = text,  
            onValueChange = { newText ->  
              // テキスト入力中はフォーマットなしで表示  
              text = newText  
            },  
            textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Right),  
            singleLine = true,  
            modifier = Modifier.onFocusChanged { focusState ->  
              if (!focusState.isFocused) {  
                // フォーカスが外れた時にフォーマットを適用  
                val parsed = newText.text.toDoubleOrNull() ?: 0.0  
                text = TextFieldValue(decimalFormat.format(parsed))  
                data[rowIndex][columnIndex] = parsed  
              }  
            }  
          )  
        }  
      }  
    }  
  }  
}  

このコードでは、onFocusChanged リスナーを使用して、テキストフィールドがフォーカスを失った時にのみ数値をフォーマットしています。これにより、ユーザーが値を編集している間はリアルタイムのフォーマットを適用せず、編集が完了した後で数値をクリーンにフォーマットすることができます。

既存の数値テキストを変更しようとしたときに、フォーマットを解除した数値が表示されるようにはなっていますか?

User
既存の数値テキストを変更しようとしたときに、フォーマットを解除した数値が表示されるようにはなっていますか?

ChatGPT
私が以前に提供したコードでは、編集中にフォーマットが解除された数値が表示されるようにはなっていませんでした。入力中にはフォーマットされたままの状態が保持されており、これがユーザーが編集を行う際にやや扱いづらくなる可能性があります。

編集時にフォーマットを解除し、入力が完了した後で再度フォーマットを適用するようにコードを調整することが必要です。これを実現するには、フォーカスの状態を追跡し、フォーカスがある間はフォーマットを解除したプレーンな数値を表示し、フォーカスが外れた時にフォーマットを適用するようにします。

以下のようにコードを調整できます:

@Composable  
fun FormattedEditableTable(data: MutableList<MutableList<Double>>) {  
  val decimalFormat = DecimalFormat("#,###.00") // 千単位のカンマと小数点以下2桁を設定  
  
  LazyColumn {  
    items(data.size) { rowIndex ->  
      LazyRow {  
        itemsIndexed(data[rowIndex]) { columnIndex, cellValue ->  
          var text by remember { mutableStateOf(TextFieldValue(decimalFormat.format(cellValue))) }  
          var isFocused by remember { mutableStateOf(false) }  
  
          BasicTextField(  
            value = if (isFocused) TextFieldValue(cellValue.toString()) else text,  
            onValueChange = { newText ->  
              // フォーカスがある間はフォーマット解除して状態を更新  
              if (isFocused) {  
                text = newText  
                data[rowIndex][columnIndex] = newText.text.toDoubleOrNull() ?: 0.0  
              }  
            },  
            textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Right),  
            singleLine = true,  
            modifier = Modifier.onFocusChanged { focusState ->  
              isFocused = focusState.isFocused  
              if (!focusState.isFocused) {  
                // フォーカスが外れた時にフォーマットを適用  
                val parsed = text.text.toDoubleOrNull() ?: 0.0  
                text = TextFieldValue(decimalFormat.format(parsed))  
                data[rowIndex][columnIndex] = parsed  
              }  
            }  
          )  
        }  
      }  
    }  
  }  
}  

このコードでは、isFocused 変数を追加してフォーカスの有無を追跡し、フォーカスがある間はフォーマット解除された数値を表示し、フォーカスが外れたときに数値をフォーマットしています。これにより、ユーザーが数値を直感的に編集できるようになり、編集が終了するとクリーンなフォーマットでデータが更新されます。

ありがとうございました。おかげで理解できました。LocalTextStyleについても教えてください。

User
ありがとうございました。おかげで理解できました。

LocalTextStyleについても教えてください。

ChatGPT
LocalTextStyleはJetpack Composeの一部で、現在のテキストスタイルを取得するために使用されるアンビエントです。これは、コンポーザブルツリーを通じてテキストスタイルの設定を効率的に伝播させるメカニズムを提供し、親コンポーザブルから子コンポーザブルへと継承されるデフォルトのテキストスタイルを提供します。

LocalTextStyle の使い方
LocalTextStyle.current を使用すると、その時点で利用可能な最も近い親コンポーザブルからテキストスタイルを取得できます。このスタイルは、Text コンポーザブルや TextField コンポーザブルなど、テキストを扱うすべての場所で使用されるデフォルトのスタイルを提供します。

使用例
LocalTextStyleを使うことで、特定のコンポーザブル内で一貫したテキストの見た目を保持しながら、必要に応じて部分的なカスタマイズを行うことが可能です。以下に例を示します:

@Composable  
fun CustomText() {  
  // LocalTextStyle.current を使用して、現在のテキストスタイルを取得  
  val currentStyle = LocalTextStyle.current  
  
  // 既存のスタイルを基にして新しいスタイルを作成  
  val newStyle = currentStyle.copy(  
    color = Color.Red,  
    fontSize = 20.sp,  
    fontWeight = FontWeight.Bold  
  )  
  
  Text(  
    text = "Hello, Compose!",  
    style = newStyle  
  )  
}  

この例では、LocalTextStyle.currentを使用して現在のテキストスタイルを取得し、色、フォントサイズ、フォントの太さをカスタマイズしています。この方法で、デフォルトのスタイルを基にしつつ、独自のスタイリングを適用することができます。

まとめ
LocalTextStyleを利用することで、アプリケーション全体のテキストスタイリングの一貫性を保ちつつ、特定のテキストコンポーザブルにカスタマイズされたスタイルを適用することが可能です。これにより、開発者はより簡単にアプリケーションのビジュアルデザインを管理できるようになります。

親コンポーザブルから子コンポーザブルへと継承されるデフォルト値を保持する同様なアンビエント(=Composition Local)が他にもあれば教えてください。

User
ありがとうございます。

親コンポーザブルから子コンポーザブルへと継承されるデフォルト値を保持する同様なアンビエントが他にもあれば教えてください。

ChatGPT
Jetpack Composeには、LocalTextStyleのように親コンポーザブルから子コンポーザブルへと設定や状態を継承するための複数のアンビエント(現在はComposition Localと呼ばれます)があります。これらは様々なコンテキスト情報を提供し、アプリケーションの様々な部分で一貫性を保つのに役立ちます。以下にいくつかの重要なComposition Localを示します:

LocalContentColor
現在のコンテキストで使用される推奨のコンテンツカラーを保持します。例えば、アイコンやテキストに適用されるデフォルトの色です。

LocalContentAlpha
コンテンツの不透明度を保持します。テキストやアイコンの透明度(アルファ値)を調整するときに使用します。

LocalDensity
現在の画面の密度情報を提供します。dpからpxへの変換や、その逆の計算に役立ちます。

LocalFontFamily
テキスト表示に使用されるデフォルトのフォントファミリーを保持します。

LocalFontSize
デフォルトのフォントサイズを保持します。これは、テキストコンポーザブルで使用される基本的なフォントサイズです。

LocalTextInputService
テキスト入力操作をサポートするサービスを提供します。これにより、キーボード入力やその他のテキスト入力関連の機能が管理されます。

LocalLifecycleOwner
現在のコンポーザブルが関連付けられているライフサイクルオーナーを提供します。これは、ライフサイクルイベントに基づいた動作をトリガーするときに役立ちます。

LocalContext
現在のコンテキスト(通常はアクティビティ)を提供します。リソースにアクセスしたり、インテントを作成したりするときに必要です。

これらのアンビエントは、特定のプロパティや設定をコンポーザブルツリー全体で一貫して適用することを容易にし、開発者が明示的にプロパティを各コンポーザブルに渡す手間を省くことができます。このシステムは、効率的なデータ伝達と一貫性の保持に非常に役立ちます。