1.なぜ開発するのか
アプリ開発でブランドカラーやコンポーネントの色を決める際、特定の色をベースに少し調整したり、その調整した色を保存したいと思うことがよくあった。
またその色と相性の良い色を提案してくれる機能があったら便利だなと感じ、そういった機能を実現するアプリを開発することに決めた。
2.アプリの概要
2つのViewからなるアプリで役割は以下の通り
ColorChoiceScreen
色同士の相性を確認するためにプレビュー用の正方形を2つ用意した(以下この正方形をパレットと呼ぶ)
【色の作り方】
- 2つのパレットを操作できる。操作中のパレットは枠線が強調される
- ユーザーがRGBシークバーを調整してオリジナルの色とそのカラーコードを作成する
- カラーコード表示部分に特定の色のカラーコードを入力すると色を表示する
- RGB値を直接入力する又はRGB値増減ボタンで調整することができる
- RGB値の横のラジオボタンをクリックすることで操作するスライダーを指定する
【色を選ぶ】
- 基本の色タブから用意された色を選ぶことができる
- カラーパレットを作るタブでAPI通信を行い選択した色と相性の良い色を提案する
- カラーパレット作成モードを変更することができる
【保存する】
- ➕ボタンからカラーコードと名前とメモを保存する
FavoriteColorList
- 保存した色をフィルタリングして検索することができる
- 右上ボタンからリストの並び替え
- 【個別のカードの右上のハンバーガーボタンから以下のことができる】
1.削除
2.カラーコードのコピー
3.名前とメモの変更
4.保存した色をColorChoiceScreenのパレットに表示することができる(この時ColorChoiceScreenに自動遷移)
3.使用技術と技術選定理由
1. 開発言語:Kotlin
2.UIフレーム:JetPackCompose
シークバーの動きに合わせてプレビューのコードが変わるなどリアクティブ的な機能が多かったのでXMLよりも適していると考え選定した。
3.アーキテクチャ:MVVM
・パレットの数が2つで手持ちデータが多くなるので(RGB*2等々)状態管理の簡素化を狙った
・パレットとスライダー周りのコンポーネントのリコンポーズが頻繁に行われる構造故にそれ以外のViewを切り離すため
4.データベース:ROOM
・KSPを使用してSQLの簡略化ができる
・自動生成でミスが減る
・SQLite程の機能は不要
5.ライブラリ:
1. Moshi・KSP
・APIで受け取ったJSONをMoshiで変換する
・KSPを同時に使うことでシリアライズ・デシリアライズが自動でできる
2. Retrofit
・HTTPリクエストとレスポンスを簡単に扱うため
・エラーハンドリングがtryCatchで簡潔に書けるから
3. Coroutine
・非同期処理を管理するため
・シンプルに記述できる
・エラーハンドリングがtry catchで簡潔に書けるから
4. LiveData
・状態を変化させるために使用
・Composeを導入する前のプロジェクトでLiveDataを使用していたため、実装の一貫性を重視してそのままLiveDataを採用した
5. Navigation Compose
・jetpackComposeで画面遷移をスムーズに行うため使用
6. Material3
・jetpackComposeとの相性
7. Junit
・データベースのテストを行うため選定
8.API:The Color API
カラーコードを引き渡してカラーパレットを返すことが特徴のAPI
https://www.thecolorapi.com/
3. 課題と解決策・実装の工夫
⭐️コンポーネントファイルの構造
MainActivity.kt
├── BottomBar.kt
│ └── BottomBarTab.kt
│
├── ColorChoiceScreen.kt
│ ├── ColorSaveDialog.kt
│ ├── AdjustValueBar.kt
│ │ └── AdjustValueChangeMenu.kt
│ ├── ColorPickerTabs
│ │ └── ColorPalletContents
│ │ └── ChangePalletModeButton.kt
│ └── BasicColorContents.kt
│
└── FavoriteListScreen.kt
├── ColorUpdateDialog.kt
├── FavoriteColorActionsMenu.kt
└── FavoriteColorSortMenu.kt
1. MainActivity
課題1:ボトムバーを導入して直感的でスムーズな画面遷移を行いたい
- JetpackCompose導入前のアプリでは、複数のActivityやFragmentを用いて画面遷移を管理していたが、ライフサイクル管理やデータの受け渡しをシンプルにしたいのでNavControllerを活用し、ActivityからViewの役割を持つComposableを起動する形にした
- 画面遷移がシンプルになりViewModelとの連携もスムーズになった
- MainActivityがNavHostを利用して画面遷移やデータの受け渡しを仲介することで、構成がシンプルになった
課題2:ユーザーが入力時にキーボード起動時に画面外をタップすると非表示にしたい、またエンターキー入力時にキーボードを非表示にしたい
- pointerInput と detectTapGestures を使用し、画面タップ時にキーボードを自動で非表示に設定
- Surfaceの.onKeyEventでエンターキー入力時にもキーボード非表示を実装
- この機能はColorChoiceScreen・FavoriteListScreenだけでなく、ColorSaveDialogやColorUpdateDialogにも適用
{ padding ->
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
Surface(
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures(onTap = {
keyboardController?.hide()
focusManager.clearFocus()
})
}
.onKeyEvent { keyEvent ->
if (keyEvent.type == KeyEventType.KeyUp && keyEvent.key == Key.Enter) {
focusManager.clearFocus()//フォーカスを解除
keyboardController?.hide()//キーボードを隠す
true
} else {
false
}
},
color = Color.Transparent
)
}
課題3:FavoriteListScreenからColorChoiceScreenへのデータ受け渡しをスムーズに行いたい
- 実機テスト時、単純にonClickでColorChoiceScreenを呼び出していたため、新しいインスタンスが作成されてしまった
- その結果、ViewModelの状態が引き継がれず、意図しない動作が発生
- 同じ画面間の遷移(FavoriteListScreen → ColorChoiceScreen → FavoriteListScreen)で、BackStackにインスタンスが積み重なり、データが受け取られずパレットに色が反映されない
解決策・工夫
- 実機でログを確認しインスタンスが重複してしまう原因を特定
- saveState = trueで削除前の状態を保持し、画面復帰時にユーザーの操作状況を復元
- navController.navigateで画面遷移する際にpopUpToを使い遷移元の画面インスタンスをBackStackから削除
結果
- BackStackの積み重ねがなくなり、画面遷移が安定し正しくデータの受け渡しができるようになった
- 積み重ねがなくなったことでアプリの動作が軽量化された
実機の動き
実装内容
1.画面遷移のルート設定
//MainActivityに表示するのは以下のフラグメント
//RouteはenumClassのBottomBarTabから取得
composable("${BottomBarTab.ColorChoice.route}?direction={direction}&colorCode={colorCode}") {
ColorChoiceScreen(navController, colorChoiceViewModel)
}
composable(BottomBarTab.FavoriteList.route) {
FavoriteColorList(navController, favoriteScreenViewModel)
}
2.画面遷移のトリガー
fun moveToColorChoice(navController:NavHostController, direction:String, colorCode:String){
//色コードに含まれる#がURLに正しく変換できないからエンコードする
val encodedColorCode = URLEncoder.encode(colorCode,"UTF-8")
//URL形式で出力し指定したViewに遷移する(移動先:route colorChoice(ColorChoiceScreen))
//directionとencodedColorCodeパラメータを引き渡す
navController.navigate(
route = "colorChoice?direction=${direction}&colorCode=${encodedColorCode}",
navOptions = navOptions { popUpTo("favoriteList") {saveState = true} },
)
}
3.ボトムバータップ時のBackStack管理方法
//ボトムバークリック時の動作を定義
onClick = {
navController.navigate(
route = item.route,
navOptions = navOptions {
//現在開いているViewのrouteをpopUpToに渡しそのビューをバックスタックから削除する
// 削除Viewの状態保存をしておくことで再度表示した時に前回の状態の復元する
popUpTo(BottomBarTab.entries[currentBottomBarItem].route){ saveState= true }
}
)
}
4.ColorChoiceScreenで受け取り後Viewを更新
//FavoriteListScreenでユーザーが選択した色をColorChoiceScreenのカラーパレット(左右いずれか)に表示する
//View間の移動を行った初回のみ実行する
LaunchedEffect(navController.currentBackStackEntry) {
//ColorFavoriteScreenから遷移した時にnavHostに保存されてるデータを取得する
val receiveDirection =
navController.currentBackStackEntry?.arguments?.getString("direction")
val receiveColorCode =
navController.currentBackStackEntry?.arguments?.getString("colorCode")
if (receiveDirection == null || receiveColorCode == null) {
//処理を行わない
} else {
省略(View更新処理)
}
}
2. ColorChoiceScreen
課題1:画面遷移時にデータ受け渡しの際、コンポーネントが競合して意図しない初期化が発生する
解決策・工夫
- LaunchedEffect内で渡されたデータ(value)が"255"の場合は、初回フラグがtrueの時にをスキップすることで意図しない更新を防止
- これにより初期化を防ぎ渡されたカラーコードを正しく反映できる
LaunchedEffect(value) {
if (isInitialLoad.value) {
if (value == "255") {
isInitialLoad.value = false // 初回のみスキップ
return@LaunchedEffect
}
}
if (value?.toIntOrNull() in 0..255) {
viewModel.validAndUpdateRGBValue(
value,
currentSquareIndex,
sliderColorName,
false
)
}
}
課題2:RGBの更新処理をシンプルにしたい
・RGB値の更新に、➕➖ボタンとスライダーの2種類の入力方法がある。それぞれに対応するため、ボタン用とスライダー用で別々の処理メソッドが存在していた
・同じRGBの更新処理が重複していたことで、コードの管理や変更が面倒なので統一したい
解決策・工夫
- validAndUpdateRGBValueとcurrentRGBValueChangeを作成し、どちらの入力方法(スライダー/➕➖ボタン)でも一貫した処理が行えるように統一
- 入力値が数字か検証するメソッドと計算を行うメソッドに分けることで責任を分散
- これにより、メソッドの役割が明確になり、メソッドの変更がしやすくなり可読性も向上した
//入力値が数値かをチェックし数値でない場合は0にすることでエラーを防ぐ
fun validAndUpdateRGBValue(
inputValue: String?,
currentSquareIndex: Int,
rgbColorType: String,//red,greeb,blue
isAdjustment: Boolean = false//増減処理かどうか
) {
val value = inputValue?.toIntOrNull() ?: 0
currentRGBValueChange(currentSquareIndex, rgbColorType, value, isAdjustment)
}
//RGB値の計算を行い更新する
fun currentRGBValueChange(
currentSquareIndex: Int,
rgbColorType: String,
value: Int,
isAdjustment: Boolean = false
) {
val updateValue: (Int) -> Int = { currentValue ->
if (isAdjustment) (currentValue + value).coerceIn(0, 255)
else value.coerceIn(0, 255)
}
when (currentSquareIndex) {
1 -> when (rgbColorType) {
"red" -> red1.value = red1.value?.let { updateValue(it) }
"green" -> green1.value = green1.value?.let { updateValue(it) }
"blue" -> blue1.value = blue1.value?.let { updateValue(it) }
}
2 -> when (rgbColorType) {
"red" -> red2.value = red2.value?.let { updateValue(it) }
"green" -> green2.value = green2.value?.let { updateValue(it) }
"blue" -> blue2.value = blue2.value?.let { updateValue(it) }
}
}
//RGB値からカラーコードを生成
convertToColorCode(currentSquareIndex)
}
課題3:ユーザーが外部で取得したカラーコードを調整・保存できるようにしたい
解決策・工夫
1.カラーコードを直接テキストフィールドに入力できるコンポーネントへ変更した
- ColorChoiceViewModelでの管理方法の改善
実装当初は表示用のカラーコードと背景カラーコードを1つのプロパティで管理していた - これをcolorCodeとbackgroundColorCodeの2つに分けて管理した
2.convertToHexColorCodeによるカラーコードの検証
- ユーザーが入力したカラーコードが正しいかを、ColorChoiceViewModelでconvertToHexColorCodeメソッドを使用して検証する
- 正しいカラーコードであれば、BackgroundColorCodeを更新し、ビューに反映する
//色名→Hex、Hex検証
//ユーザーが入力した色名をHexに変換する
//Hexが正しい値か検証する際にも使用する
fun convertToHexColorCode(text: String): String? {
//TextFieldが空の時はnullを返し呼び出し元で処理を行わないようにする
val trimText = text.trim()//スペースを削除
if (trimText.isEmpty()) {
return null
}
return try {
//parseColorで変換した値
val intColorCode = Color.parseColor(text)
//colorInt(上2桁は透明度、下4桁はRGB)から透明度を無視するAND演算を行いRGB部分だけ取得する
//0xはプレフィックスで数値が16進数であることを示す
val rgbColorCode = intColorCode and 0x00FFFFFF
String.format("#%06X", rgbColorCode)
} catch (e: IllegalArgumentException) {
//入力されたtextからColorCodeが見つからない場合nullを返す
null
}
}
//ユーザーに表示するカラーコードテキスト
//ユーザーが値を入力するなどしてvalueが変更されるとcolorCodeの変更を行い、
// 検査後にバッグラウンドカラーコードとRGB値の更新を行う
ColorCodeText(
modifier = Modifier.weight(2f).padding(top = 5.dp),
colorCode = colorData.colorCode,
onSquareSelected = { viewModel.changeCurrentSquareIndex(squareIndex) },
onValueChanged = {
newValue ->
viewModel.updateColorCode(squareIndex, newValue)
val colorCode = viewModel.convertToHexColorCode(newValue)
if (colorCode != null) {
//背景の色の変更とSeekBarの値の変更を行う
viewModel.updateBackgroundColorCode(squareIndex, colorCode)
viewModel.convertToRGB(currentSquareIndex)
} else {
//nullの場合(colorCodeに誤った値が入力されている時)は処理を行わない
}
},
)
課題4:2つのパレットを使うために操作を切り替える
解決策・工夫
- currentSquareIndexをviewModelでMutableLiveDataとして管理し、Viewで監視する
- ユーザーがパレット(ColorSquare)をタップするとcurrentSquareIndexが更新され、選択されているパレットが視覚的にわかるようにする(ColorSquareのisSelected部分で枠線を強調表示)
- 以下のコードでcurrentSquareIndexを自動的に更新している
val isSelected: Boolean = squareIndex == currentSquareIndex
// パレットの表示
ColorSquare(
modifier = Modifier
.weight(0.6f)
.fillMaxWidth(),
backgroundColor = colorData.backgroundColorCode,
isSelected = isSelected, // 選択状態でデザイン変更
onSquareSelected = {
// タップされたSquareのインデックスをcurrentSquareIndexに反映
viewModel.changeCurrentSquareIndex(squareIndex)
},
)
課題5:API通信をその後の変更に耐えうる設計にしたい
使用するTheColorAPIはオプションが用意されており、
将来的には新たなオプションを追加するかも。
こうした変更に柔軟に対応できるよう、Repositoryパターンを採用した。
解決策・工夫
- try catchブロックを活用しエラー時にユーザーへの通知を実装し、アプリのクラッシュも防いだ
- apiServiceインターフェース、Repository,RepositoryImplに分離することで、パラメータを変更するときもImplのみの修正ですみ、実際の動作を定義するRepository層には手が触れないようにした
反省:DIを使用しなかったため、Repositoryパターンのテストの容易さを活用しきれなかった
- DIを導入していればテスト時にapiServiceのモックのモックを注入できJunitなどでのユニットテストが行いやすかった
- 依存関係を手動で注入する必要があり、Junitテストの効率性に欠けた
interface TheColorApiService {
@GET("scheme")
suspend fun getColorScheme(
@Query("hex") colorCodeWithoutHash: String,
@Query("mode") mode: String = "analogic",
@Query("format") format: String = "json",
@Query("count") count: Int = 5,
): ColorSchemeResponse
}
interface ColorSchemeRepository {
suspend fun getColorScheme(
colorCodeWithoutHash: String,
mode: String,
format: String = "json",
count: Int = 5
): List<String>
}
- エラーハンドリングでクラッシュを防ぎユーザーに通知を行う
override suspend fun getColorScheme(
colorCodeWithoutHash: String,
mode: String,
format: String,
count: Int
): List<String> {
return try {
val responce = apiService.getColorScheme(colorCodeWithoutHash, mode, format, count)
responce.colors.map { it.hex.value }
}catch (e:UnknownHostException){//インターネット接続エラーなし
Log.e("RepositoryImpl${e.message}", e.toString())
throw e
} catch (e: SocketTimeoutException) {//タイムアウト
Log.e("RepositoryImpl${e.message}", e.toString())
throw e
} catch (e: HttpException) {//サーバーエラー
Log.e("RepositoryImpl${e.message}", e.toString())
throw e
} catch (e:Exception){//それ以外のエラー
Log.e("RepositoryImpl${e.message}", e.toString())
throw e
}
}
3. FavoriteListScreen
課題1:ソート順に基づいてリストを並び替える処理
解決策・工夫
- ベストプラクティスに沿ってrememberを利用することで、並び替え処理が行われるたびに計算結果を保持し、必要な場合のみリコンポーズを行う
- 無駄な計算やリコンポーズを避けUIのパフォーマンスを向上させる
//displayColors,sortOrderのどちらかが変更された時に並び替えを行う
val sortedContacts =remember(displayColors, currentSortOrder) {
val comparator: Comparator<FavoriteColorDataClass> =
when (currentSortOrder) {
0 -> compareByDescending { it.editDateTime }
1 -> compareBy { it.editDateTime }
else -> {
compareByDescending { it.editDateTime }
}
}
displayColors.sortedWith(comparator)
}
//LazyColumnはsortedContactsを表示する
//データベースに含まれるカラーデータを表示する
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(sortedContacts) { color ->以下略
課題2:ユーザーが直感的に使いやすいフィルターを作りたい
解決策・工夫
1. 複数キーワードでAND検索に対応
スペース区切りで1ワードとみなし複数の条件を同時に検索できるようにした
2. 表記揺れ対策
カタカナとひらがな、全角英数字と半角英数字の区別なく検索できるようにした
3. 検索対象をすべての要素に設定
カラーコード、カラー名、メモ、編集日いずれも検索対象
4. リアルタイムでフィルタリング結果を表示した
fun filter() {
val filterText = _filterText.value ?: ""
_filteredColors.value = if (filterText.isEmpty()) {
// 検索欄が空なら全データ表示
allColors.value
} else {
val filteredColorList: MutableList<FavoriteColorDataClass> = mutableListOf()
val allColorsList = allColors.value ?: emptyList()
// スペース区切りでキーワード分割
val keywords = filterText.split(" ", " ")
val normalizedKeyWords = keywords.map { word -> convertToFilterWard(word) }
for (color in allColorsList) {
val editeDate: String = SimpleDateFormat("yyyy/MM/dd", Locale.getDefault())
.format(Date(color.editDateTime))
// AND条件でフィルタリング
val filteredAllKeyWords = normalizedKeyWords.all { word ->
convertToFilterWard(color.colorCode).contains(word, ignoreCase = true) ||
convertToFilterWard(color.colorName).contains(word, ignoreCase = true) ||
convertToFilterWard(color.colorMemo).contains(word, ignoreCase = true) ||
convertToFilterWard(editeDate).contains(word, ignoreCase = true)
}
if (filteredAllKeyWords) {
filteredColorList.add(color)
}
}
filteredColorList
}
}
4.UI/UXの課題
課題1:Viewは固定サイズを指定したコンポーネントが多く画面サイズによっては崩れた
画面サイズによって可変する方式も試したが効果は高くない
→weightによってサイズを決定しなるべく固定サイズを使用しない
課題2:機種によって異なるシステムバーの高さでレイアウトが崩れた
解決策・工夫
- システムバーの高さを自動的に検出してパディングを調整し、すべてのデバイスでレイアウトを安定させる
実装内容
1.システムバーの高さを検証する
//画面のtopパディングを設定するためシステムバーの高さを検証する
val isSystemBarsPaddingZero = systemBarsPadding.run {
calculateTopPadding() == 0.dp &&
calculateBottomPadding() == 0.dp &&
//LTRはstart,endの方向が国によって違うのでLtrまたはRtlで指定する(Ltrは左がstart,右がend)
calculateStartPadding(LayoutDirection.Ltr) == 0.dp &&
calculateEndPadding(LayoutDirection.Ltr) == 0.dp
}
//システムバーの高さが0より大きい場合topPaddingを設定
val topPadding = if (isSystemBarsPaddingZero)Dimensions.screenVerticalPadding else 0.dp
2.レイアウトへ反映する
Column(
modifier = Modifier
.padding(systemBarsPadding)
//実機のナビゲーションバーなどの高さ分パディングを入れる
.padding(
top = topPadding,
start = Dimensions.screenHorizontalPadding,
end = Dimensions.screenHorizontalPadding
)
)
5.テストとデバッグ
1.データベースのビルドテスト
データベースのビルド に関して以下のテストを実施
- テーブルの構造確認 :指定された通りにテーブルが作成されているかを確認。ログを出力して、テーブルのカラム名や構造が正しいか確認した
- データ挿入確認 :データをインサート後正しく追加されたかを確認するためログ出力を行い、結果を確認した
- カラム名変更のテスト :既存のカラム名を変更する場合を想定し、旧カラムコピー→新カラム作成→データ移行
上記のテストを実施した。
しかし、カラム名の移行が不要になり削除した。
2.API通信テスト
ライブラリ:Junit
テスト内容
指定したカラーコードを渡し、正しいスキームを取得できることを確認
1.テスト対象の機能: 色のスキームを取得するAPI機能
2.検証内容: レスポンスが 適切な色スキームであること 、レスポンススキームの数 が指定通りであるかを確認した
テストコード
package com.example.findcolorcode.repository
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class ColorSchemeRepositoryImplTest {
private val repository = ColorSchemeRepositoryImpl()
@Test
fun testGetColorScheme() = runBlocking {
val colorCode = "ff0000"
val colorList = repository.getColorScheme(colorCode,"analogic", "json",5)
assertNotNull(colorList)
assertEquals(5,colorList.size)
colorList.forEach{
colorCode ->
println("HexValue:$colorCode")
}
}
}
6. 運用と今後の改善点
1. DI(依存性注入)の導入を検討
今回はDIを導入しなかったが、導入することで依存関係の管理がしやすくなり、より柔軟で効率的なテストが可能になると感じた。今後は積極的に取り入れる。
2. API通信中の処理制御の強化
API通信時にrunBlocking
を活用することで、よりスムーズな処理の制御ができたと考えている。今後は処理の最適化に注力して、ユーザー体験の向上を目指す。
3. プログレス表示の導入
通信状況に応じたレスポンスの差を改善するために、処理中であることを示すプログレス表示を導入する。これにより、ユーザーに安心感を与え、操作性も向上できると考えている
4. テストの精度向上
今回の経験から、より細かい単位でのテストが重要であると実感した
今後はテストケースの網羅性を高め、品質向上に努める
5. StateFlowの積極的な活用
プロジェクトの継続性を考えてLiveData
を採用したが、Jetpack Composeとの親和性やライフサイクル管理の観点からStateFlow
の方が適していると感じた
今後はより最適な技術選定を行い、柔軟な開発を進める
6. 色のライブラリ機能追加
一般的な色や人気の色を簡単に探せるように、色のライブラリ機能を追加する予定。
ColorChoiceScreenに配置し、ボタンをタップすると画面右側から左側へスライドするアニメーションで表示されるようにする。
これにより、直感的に色を選びやすくなり、使いやすさが向上する。
7. 現在の状態 クローズドテスト進行中
1. 2025/1/8:バージョンコード2
ユーザーのフィードバックを受け「RGB値に255を入力できない不具合」を修正
2. 2025/1/14:バージョンコード3
新機能を追加[カラースキームのモードを変更できる機能]を追加