【Jetpack Compose】記事取得アプリを作ってみる
初めに
今回は Jetpack Compose を用いて記事の一覧を取得するためのアプリケーションを作成してみたいと思います。記事の取得には Retrofit を用いるので、その使い方を中心に見ていきたいと思います。
Jetpack Compose のドキュメントでインターネットからデータを取得するの章で Retrofit を用いてデータの取得を行う実装があったので、それを別の形で使ってみたいと思います。
記事の対象者
- Jetpack Compose 初学者
- Retrofit に触れてみたい方
目的
今回の目的は、先述の通り Jetpack Compose で記事取得アプリを作ることです。
最終的には以下の動画のように Zenn の記事をトピックごとやユーザーごとに取得できるようなアプリを作っていきます。
また、今回実装するコートは以下で公開しているので、よろしければご参照ください。
実装
実装は以下の手順で進めていきたいと思います。
- Model の実装
- Repository の実装
- Network の実装
- View, ViewModel の実装
- Application, MainActivity の実装
1. Model の実装
まずは Model の実装を進めていきます。
コードは以下の通りです。
Article
package com.example.zennapp.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Article(
@SerialName(value = "article_type")
val articleType: String,
@SerialName(value = "body_letters_count")
val bodyLettersCount: Int,
@SerialName(value = "body_updated_at")
val bodyUpdatedAt: String,
@SerialName(value = "comments_count")
val commentsCount: Int,
val emoji: String,
val id: Int,
@SerialName(value = "is_suspending_private")
val isSuspendingPrivate: Boolean,
@SerialName(value = "liked_count")
val likedCount: Int,
val path: String,
val pinned: Boolean,
@SerialName(value = "post_type")
val postType: String,
val publication: Publication?,
@SerialName(value = "published_at")
val publishedAt: String,
val slug: String,
@SerialName(value = "source_repo_updated_at")
val sourceRepoUpdatedAt: String? = null,
val title: String,
val user: User
)
Publication
package com.example.zennapp.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Publication(
val id: Int,
val name: String,
@SerialName("display_name")
val displayName: String,
@SerialName("avatar_small_url")
val avatarSmallUrl: String,
val pro: Boolean,
@SerialName("avatar_registered")
val avatarRegistered: Boolean
)
User
package com.example.zennapp.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Suppress("PLUGIN_IS_NOT_ENABLED")
@Serializable
data class User(
@SerialName(value = "avatar_small_url")
val avatarSmallUrl: String,
val id: Int,
val name: String,
val username: String
)
ZennArticle
package com.example.zennapp.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ZennArticle(
val articles: List<Article>,
@SerialName(value = "next_page")
val nextPage: Int?
)
これらのコードは以下のような Zenn のURLのレスポンスをまとめることで実装できます。
quicktype などのツールを使って JSON のデータから Kotlin のデータを生成できます。今回は kotlinx を使うので、以下のように言語を「Kotlin」に設定し、Serialization framework を「Kotlinx」に設定して生成してみます。
これで Model の実装は完了です。
2. Repository の実装
次に Repository の実装を行います。
まずは記事の取得を行う Repository の実装を行います。
コードは以下の通りです。
package com.example.zennapp.data
import com.example.zennapp.model.ZennArticle
import com.example.zennapp.network.ZennApiService
interface ZennArticlesRepository {
suspend fun getArticles(): ZennArticle
suspend fun getLatestArticles(): ZennArticle
suspend fun getMyArticles(): ZennArticle
suspend fun getMyLatestArticles(): ZennArticle
suspend fun getTopicsArticle(topicName: String): ZennArticle
suspend fun getTopicsLatestArticle(topicName: String): ZennArticle
}
class NetworkZennArticlesRepository(
private val zennApiService: ZennApiService,
private val username: String
) : ZennArticlesRepository {
override suspend fun getArticles(): ZennArticle = zennApiService.getArticles()
override suspend fun getLatestArticles(): ZennArticle =
zennApiService.getLatestArticles()
override suspend fun getMyArticles(): ZennArticle =
zennApiService.getMyArticles(username)
override suspend fun getMyLatestArticles(): ZennArticle =
zennApiService.getMyLatestArticles(username)
override suspend fun getTopicsArticle(topicName: String): ZennArticle =
zennApiService.getTopicsArticles(topicName = topicName)
override suspend fun getTopicsLatestArticle(topicName: String): ZennArticle =
zennApiService.getTopicsLatestArticles(topicName = topicName)
}
それぞれ詳しくみていきます。
以下では interface
として ZennArticlesRepository
を定義しています。
それぞれのメソッドはコメントにある通りです。
今回は Zenn のAPIからのみデータを取得するため、 interface
としての Repository は必要ないかもしれませんが、用意するべきメソッドがわかりやすいように設けておきます。
interface ZennArticlesRepository {
suspend fun getArticles(): ZennArticle // 通常の記事取得
suspend fun getLatestArticles(): ZennArticle // 最新の記事取得
suspend fun getMyArticles(): ZennArticle // 自分の記事の取得
suspend fun getMyLatestArticles(): ZennArticle // 自分の最新の記事取得
suspend fun getTopicsArticle(topicName: String): ZennArticle // トピックごとの記事取得
suspend fun getTopicsLatestArticle(topicName: String): ZennArticle // トピックごとの最新の記事取得
}
以下では NetworkZennArticlesRepository
として、 ZennArticlesRepository
を継承した Repository を作成しています。
引数としてAPIの処理を行う ZennApiService
とユーザー名である username
を受け取っています。
ZennArticlesRepository
に用意されているメソッドを実装しており、基本的には ZennApiService
にあるメソッドを実行しているだけになります。具体的な実装は ZennApiService
に用意されています。
class NetworkZennArticlesRepository(
private val zennApiService: ZennApiService,
private val username: String
) : ZennArticlesRepository {
override suspend fun getArticles(): ZennArticle = zennApiService.getArticles()
override suspend fun getLatestArticles(): ZennArticle =
zennApiService.getLatestArticles()
override suspend fun getMyArticles(): ZennArticle =
zennApiService.getMyArticles(username)
override suspend fun getMyLatestArticles(): ZennArticle =
zennApiService.getMyLatestArticles(username)
override suspend fun getTopicsArticle(topicName: String): ZennArticle =
zennApiService.getTopicsArticles(topicName = topicName)
override suspend fun getTopicsLatestArticle(topicName: String): ZennArticle =
zennApiService.getTopicsLatestArticles(topicName = topicName)
}
次に AppContainer
の実装を行います。
コードは以下の通りです。
package com.example.zennapp.data
import com.example.zennapp.network.ZennApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
interface AppContainer {
val zennArticlesRepository: ZennArticlesRepository
}
class DefaultAppContainer : AppContainer {
private val username = "koichi_51"
private val baseUrl = "https://zenn.dev/api/"
private val contentType = "application/json".toMediaType()
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
private val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(json.asConverterFactory(contentType))
.build()
private val retrofitService: ZennApiService by lazy {
retrofit.create(ZennApiService::class.java)
}
override val zennArticlesRepository: ZennArticlesRepository by lazy {
NetworkZennArticlesRepository(retrofitService, username)
}
}
それぞれ詳しくみていきます。
以下では、 interface
として、 AppContainer
を定義しています。
AppContainer
では ZennArticlesRepository
を受け取るようにしています。
interface AppContainer {
val zennArticlesRepository: ZennArticlesRepository
}
以下では AppContainer
を継承した DefaultAppContainer
を作成しています。
Zenn のユーザー名やベースのURL、JSONの形式などを指定しています。
class DefaultAppContainer : AppContainer {
private val username = "koichi_51"
private val baseUrl = "https://zenn.dev/api/"
private val contentType = "application/json".toMediaType()
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
以下では、 Retrofit.Builder()
で Zenn のAPIのURLを指定し、 addConverterFactory
でJSONの形式を指定しています。
また、 retrofitService
では ZennApiService
を継承し、 retrofit.create
メソッドでサービスに実装されたAPIのエンドポイントを定義しています。
DefaultAppContainer
は AppContainer
を継承しており、 AppContainer
は ZennArticlesRepository
を受け取る必要があるため、 retrofitService
と username
を渡すようにしています。
private val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(json.asConverterFactory(contentType))
.build()
private val retrofitService: ZennApiService by lazy {
retrofit.create(ZennApiService::class.java)
}
override val zennArticlesRepository: ZennArticlesRepository by lazy {
NetworkZennArticlesRepository(retrofitService, username)
}
これで Repository と AppContainer の実装は完了です。
3. Network の実装
次に Network の実装に移ります。
コードは以下の通りです。
package com.example.zennapp.network
import com.example.zennapp.model.ZennArticle
import retrofit2.http.GET
import retrofit2.http.Query
interface ZennApiService {
@GET("articles")
suspend fun getArticles(): ZennArticle
@GET("articles")
suspend fun getLatestArticles(
@Query("order") order: String = "latest"
): ZennArticle
@GET("articles")
suspend fun getMyArticles(@Query("username") username: String): ZennArticle
@GET("articles")
suspend fun getMyLatestArticles(
@Query("username") username: String, @Query("order") order: String = "latest"
): ZennArticle
@GET("articles")
suspend fun getTopicsArticles(@Query("topicname") topicName: String): ZennArticle
@GET("articles")
suspend fun getTopicsLatestArticles(
@Query("topicname") topicName: String, @Query("order") order: String = "latest"
): ZennArticle
}
それぞれ詳しくみていきます。
以下では、ZennApiService
として、 Zenn のAPIからデータを取得する実装を行なっています。
getArticles
メソッドでは、 @GET
メソッドで articles
にアクセスしてデータを取得するようにしています。返り値としては ZennArticle
を取るようにしています。
baseUrl
は https://zenn.dev/api/
であるため、 https://zenn.dev/api/articles
に対して GET メソッドを実行していることになります。
interface ZennApiService {
@GET("articles")
suspend fun getArticles(): ZennArticle
以下では、getArticles
メソッドと同様に articles
に対して GET メソッドを実行しています。
@Query
ではクエリパラメータを指定することができます。 order
クエリに対して latest
を指定することで、最新の記事を取得することができるようになります。
@GET("articles")
suspend fun getLatestArticles(
@Query("order") order: String = "latest"
): ZennArticle
その他のメソッドでは username
や topicname
などを指定して取得しています。
4. View, ViewModel の実装
次に、 View, ViewModel の実装を行います。
実装は以下の手順で進めていきたいと思います。
- NavHost の実装
- ArticlesScreen, ErrorScreen, LoadingScreen の実装
- HomeScreen の実装
- MyPageScreen の実装
- TopicsScreen の実装
1. NavHost の実装
まずはそれぞれのページをタブでまとめる NavHost
の実装を進めます。
コードは以下の通りです。
package com.example.zennapp.ui.theme.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.example.zennapp.ui.theme.ZennTopAppBar
import com.example.zennapp.ui.theme.screens.home.HomeScreen
import com.example.zennapp.ui.theme.screens.home.ZennHomeScreenViewModel
import com.example.zennapp.ui.theme.screens.mypage.MyPageScreen
import com.example.zennapp.ui.theme.screens.mypage.ZennMyPageScreenViewModel
import com.example.zennapp.ui.theme.screens.topics.TopicsScreen
import com.example.zennapp.ui.theme.screens.topics.ZennTopicsScreenViewModel
sealed class Screen(val route: String, val label: String, val icon: @Composable () -> Unit) {
data object Home :
Screen("home", "Home", { Icon(Icons.Default.Home, contentDescription = null) })
data object Topics :
Screen("topics", "Topics", { Icon(Icons.Default.List, contentDescription = null) })
data object MyPage :
Screen("my_page", "My Page", { Icon(Icons.Default.Person, contentDescription = null) })
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ZennAppScaffold() {
val navController = rememberNavController()
val items = listOf(Screen.Home, Screen.Topics, Screen.MyPage)
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { ZennTopAppBar(scrollBehavior = scrollBehavior) },
bottomBar = {
NavigationBar {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
NavigationBarItem(
icon = { screen.icon() },
label = { Text(screen.label) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
) { innerPadding ->
MyAppNavHost(navController = navController, modifier = Modifier.padding(innerPadding))
}
}
@Composable
fun MyAppNavHost(
navController: NavHostController,
modifier: Modifier = Modifier,
startDestination: String = Screen.Home.route
) {
val zennHomeScreenViewModel: ZennHomeScreenViewModel =
viewModel(factory = ZennHomeScreenViewModel.Factory)
val zennMyPageScreenViewModel: ZennMyPageScreenViewModel =
viewModel(factory = ZennMyPageScreenViewModel.Factory)
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier
) {
composable(Screen.Home.route) {
HomeScreen(
zennUiState = zennHomeScreenViewModel.zennUiState,
retryAction = zennHomeScreenViewModel::getArticles,
getArticles = zennHomeScreenViewModel::getArticles,
getLatestArticles = zennHomeScreenViewModel::getLatestArticles
)
}
composable(Screen.MyPage.route) {
MyPageScreen(
zennUiState = zennMyPageScreenViewModel.zennUiState,
retryAction = zennMyPageScreenViewModel::getMyArticles,
getArticles = zennMyPageScreenViewModel::getMyArticles,
getLatestArticles = zennMyPageScreenViewModel::getLatestArticles
)
}
composable(Screen.Topics.route) {
TopicsScreen(
viewModel = viewModel(factory = ZennTopicsScreenViewModel.Factory)
)
}
}
}
タブのナビゲーションは rememberNavController
で管理します。
タブの実装は NavHost
を用いて実装しています。
2. ArticlesScreen, ErrorScreen, LoadingScreen の実装
次に記事一覧画面、エラー時の画面、ローディング時の画面を実装していきます。
ArticlesScreen
package com.example.zennapp.ui.theme.screens
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.zennapp.model.ZennArticle
import com.example.zennapp.ui.theme.PaleLightBlue
import com.example.zennapp.ui.theme.components.ArticleCard
data class DropdownMenuItemData(
val label: String,
val onClick: () -> Unit
)
@Composable
fun ArticlesScreen(
articles: ZennArticle,
modifier: Modifier = Modifier,
menuItems: List<DropdownMenuItemData>
) {
var isMenuExpanded by remember { mutableStateOf(false) }
Surface(
color = PaleLightBlue,
modifier = Modifier.fillMaxSize()
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Tech",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(16.dp)
)
Spacer(modifier = Modifier.weight(1f))
Box {
IconButton(
onClick = {
isMenuExpanded = true
},
modifier = Modifier.padding(16.dp)
) {
Icon(Icons.Default.Menu, contentDescription = "Menu")
}
DropdownMenu(
modifier = Modifier
.clip(RoundedCornerShape(16.dp)),
expanded = isMenuExpanded,
onDismissRequest = { isMenuExpanded = false }
) {
menuItems.forEach { item ->
DropdownMenuItem(
text = { Text(item.label) },
onClick = {
isMenuExpanded = false
item.onClick()
}
)
}
}
}
}
LazyColumn(
modifier = Modifier.padding(horizontal = 16.dp)
) {
items(items = articles.articles, key = { article -> article.id }) { article ->
ArticleCard(
article,
modifier = modifier
.padding(4.dp)
.fillMaxWidth()
)
}
}
}
}
}
ErrorScreen
package com.example.zennapp.ui.theme.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.example.zennapp.R
@Composable
fun ErrorScreen(
retryAction: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(id = R.drawable.ic_connection_error),
contentDescription = "Error"
)
Text("Failed to load articles", modifier = Modifier.padding(16.dp))
Button(onClick = retryAction) {
Text("Retry")
}
}
}
LoadingScreen
package com.example.zennapp.ui.theme.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.example.zennapp.R
@Composable
fun LoadingScreen(
modifier: Modifier = Modifier
) {
Image(
painter = painterResource(id = R.drawable.loading_img),
contentDescription = "Loading",
modifier = modifier.size(200.dp)
)
}
3. HomeScreen の実装
次に HomeScreen の View と ViewModel を作成していきます。
コードは以下の通りです。
HomeScreen
package com.example.zennapp.ui.theme.screens.home
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.zennapp.ui.theme.screens.ArticlesScreen
import com.example.zennapp.ui.theme.screens.DropdownMenuItemData
import com.example.zennapp.ui.theme.screens.ErrorScreen
import com.example.zennapp.ui.theme.screens.LoadingScreen
@Composable
fun HomeScreen(
zennUiState: ZennUiState,
retryAction: () -> Unit,
getArticles: () -> Unit,
getLatestArticles: () -> Unit,
modifier: Modifier = Modifier
) {
when (zennUiState) {
is ZennUiState.Loading -> LoadingScreen(modifier = modifier.fillMaxSize())
is ZennUiState.Success -> ArticlesScreen(
zennUiState.articles,
modifier = modifier.fillMaxWidth(),
menuItems = listOf(
DropdownMenuItemData(
label = "指定なし",
onClick = getArticles
),
DropdownMenuItemData(
label = "新しい投稿順",
onClick = getLatestArticles
),
)
)
is ZennUiState.Error -> ErrorScreen(retryAction, modifier = modifier.fillMaxSize())
}
}
HomeScreen
では ZennUiState
に応じて表示させるUIを切り替えています。
データの取得に成功した場合は ArticlesScreen
、ローディング中の場合は LoadingScreen
、エラーが発生した場合は ErrorScreen
を表示させています。
ZennHomeScreenViewModel
package com.example.zennapp.ui.theme.screens.home
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import coil.network.HttpException
import com.example.zennapp.ZennArticlesApplication
import com.example.zennapp.data.ZennArticlesRepository
import com.example.zennapp.model.ZennArticle
import kotlinx.coroutines.launch
import java.io.IOException
sealed interface ZennUiState {
data class Success(val articles: ZennArticle) : ZennUiState
data object Error: ZennUiState
data object Loading: ZennUiState
}
class ZennHomeScreenViewModel(private val zennArticlesRepository: ZennArticlesRepository): ViewModel() {
var zennUiState: ZennUiState by mutableStateOf(ZennUiState.Loading)
private set
init {
getLatestArticles()
}
fun getArticles() {
viewModelScope.launch {
zennUiState = ZennUiState.Loading
zennUiState = try {
ZennUiState.Success(zennArticlesRepository.getArticles())
} catch (e: IOException) {
ZennUiState.Error
} catch (e: HttpException) {
ZennUiState.Error
}
}
}
fun getLatestArticles() {
viewModelScope.launch {
zennUiState = ZennUiState.Loading
zennUiState = try {
ZennUiState.Success(zennArticlesRepository.getLatestArticles())
} catch (e: IOException) {
ZennUiState.Error
} catch (e: HttpException) {
ZennUiState.Error
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as ZennArticlesApplication)
val zennArticlesRepository = application.container.zennArticlesRepository
ZennHomeScreenViewModel(zennArticlesRepository = zennArticlesRepository)
}
}
}
}
ZennHomeScreenViewModel
について詳しくみていきます。
ZennHomeScreenViewModel
では、 ZennArticlesRepository
を受け取っています。
zennUiState
は State として定義して、初期値は ZennUiState.Loading
としています。
また、 init
では getLatestArticles
を実行することで、初期化の段階で最新の記事を取得するようにしています。
class ZennHomeScreenViewModel(private val zennArticlesRepository: ZennArticlesRepository): ViewModel() {
var zennUiState: ZennUiState by mutableStateOf(ZennUiState.Loading)
private set
init {
getLatestArticles()
}
以下では、記事の一覧を取得するメソッドを実装しています。
zennArticlesRepository.getArticles
メソッドで記事の取得を行い、成功した場合は ZennUiState.Success
に渡すようにしています。
fun getArticles() {
viewModelScope.launch {
zennUiState = ZennUiState.Loading
zennUiState = try {
ZennUiState.Success(zennArticlesRepository.getArticles())
} catch (e: IOException) {
ZennUiState.Error
} catch (e: HttpException) {
ZennUiState.Error
}
}
}
4. MyPageScreen の実装
次に MyPageScreen
の実装を行います。
なお、基本的には HomeScreen
と同様の実装であるため、コードの詳細は割愛したいと思います。
MyPageScreen の実装
package com.example.zennapp.ui.theme.screens.mypage
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.zennapp.ui.theme.screens.ArticlesScreen
import com.example.zennapp.ui.theme.screens.DropdownMenuItemData
import com.example.zennapp.ui.theme.screens.ErrorScreen
import com.example.zennapp.ui.theme.screens.LoadingScreen
import com.example.zennapp.ui.theme.screens.home.ZennUiState
@Composable
fun MyPageScreen(
zennUiState: ZennUiState,
retryAction: () -> Unit,
getArticles: () -> Unit,
getLatestArticles: () -> Unit,
modifier: Modifier = Modifier
) {
when (zennUiState) {
is ZennUiState.Loading -> LoadingScreen(modifier = modifier.fillMaxSize())
is ZennUiState.Success -> ArticlesScreen(
zennUiState.articles,
modifier = modifier.fillMaxWidth(),
menuItems = listOf(
DropdownMenuItemData(
label = "指定なし",
onClick = getArticles
),
DropdownMenuItemData(
label = "新しい投稿順",
onClick = getLatestArticles
)
),
)
is ZennUiState.Error -> ErrorScreen(retryAction, modifier = modifier.fillMaxSize())
}
}
ZennMyPageScreenViewModel の実装
package com.example.zennapp.ui.theme.screens.mypage
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import coil.network.HttpException
import com.example.zennapp.ZennArticlesApplication
import com.example.zennapp.data.ZennArticlesRepository
import com.example.zennapp.ui.theme.screens.home.ZennUiState
import kotlinx.coroutines.launch
import java.io.IOException
class ZennMyPageScreenViewModel(private val zennArticlesRepository: ZennArticlesRepository): ViewModel() {
var zennUiState: ZennUiState by mutableStateOf(ZennUiState.Loading)
init {
getMyArticles()
}
fun getMyArticles() {
viewModelScope.launch {
zennUiState = ZennUiState.Loading
zennUiState = try {
ZennUiState.Success(zennArticlesRepository.getMyArticles())
} catch (e: IOException) {
ZennUiState.Error
} catch (e: HttpException) {
ZennUiState.Error
}
}
}
fun getLatestArticles() {
viewModelScope.launch {
zennUiState = ZennUiState.Loading
zennUiState = try {
ZennUiState.Success(zennArticlesRepository.getMyLatestArticles())
} catch (e: IOException) {
ZennUiState.Error
} catch (e: HttpException) {
ZennUiState.Error
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as ZennArticlesApplication)
val zennArticlesRepository = application.container.zennArticlesRepository
ZennMyPageScreenViewModel(zennArticlesRepository = zennArticlesRepository)
}
}
}
}
5. TopicsScreen の実装
次に TopicsScreen
の実装を行います。
こちらも基本的には HomeScreen
と同様の実装であるため、コードの詳細は割愛したいと思います。
TopicsScreen の実装
package com.example.zennapp.ui.theme.screens.topics
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.zennapp.ui.theme.screens.ArticlesScreen
import com.example.zennapp.ui.theme.screens.DropdownMenuItemData
import com.example.zennapp.ui.theme.screens.ErrorScreen
import com.example.zennapp.ui.theme.screens.LoadingScreen
import com.example.zennapp.ui.theme.screens.home.ZennUiState
import java.util.Locale
@Composable
fun TopicsScreen(
viewModel: ZennTopicsScreenViewModel,
modifier: Modifier = Modifier
) {
when (val zennUiState = viewModel.zennUiState) {
is ZennUiState.Loading -> LoadingScreen(modifier = modifier.fillMaxSize())
is ZennUiState.Success -> ArticlesScreen(
zennUiState.articles,
modifier = modifier.fillMaxWidth(),
menuItems = viewModel.topics.map { topic ->
DropdownMenuItemData(
label = topic.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() },
onClick = { viewModel.getTopicsLatestArticle(topic) }
)
}
)
is ZennUiState.Error -> ErrorScreen(
retryAction = { viewModel.getTopicsLatestArticle(viewModel.topics.first()) },
modifier = modifier.fillMaxSize()
)
}
}
ZennTopicsScreenViewModel の実装
package com.example.zennapp.ui.theme.screens.topics
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import coil.network.HttpException
import com.example.zennapp.ZennArticlesApplication
import com.example.zennapp.data.ZennArticlesRepository
import com.example.zennapp.ui.theme.screens.home.ZennUiState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import java.io.IOException
class ZennTopicsScreenViewModel(private val zennArticlesRepository: ZennArticlesRepository) : ViewModel() {
var zennUiState: ZennUiState by mutableStateOf(ZennUiState.Loading)
private set
val topics = listOf(
"jetpack", "flutter", "swift", "kotlin", "python", "javascript"
)
private var currentTopic = MutableStateFlow(topics.first())
init {
getTopicsLatestArticle(currentTopic.value)
}
fun getTopicsLatestArticle(topicName: String) {
viewModelScope.launch {
zennUiState = ZennUiState.Loading
currentTopic.value = topicName
zennUiState = try {
ZennUiState.Success(zennArticlesRepository.getTopicsLatestArticle(topicName))
} catch (e: IOException) {
ZennUiState.Error
} catch (e: HttpException) {
ZennUiState.Error
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as ZennArticlesApplication)
val zennArticlesRepository = application.container.zennArticlesRepository
ZennTopicsScreenViewModel(zennArticlesRepository = zennArticlesRepository)
}
}
}
}
これで View, ViewModel の実装は完了です。
5. Application, MainActivity の実装
最後に Application, MainActivity の実装を行います。
コードは以下の通りです。
AppContainer
を遅延初期化して、 onCreate
の中で DefaultAppContainer
を割り当てています。DefaultAppContainer
には Zenn API や Retrofit の設定が含まれるため、これで ZennArticlesApplication
でデータの取得が行えるようになります。
ZennArticlesApplication
package com.example.zennapp
import android.app.Application
import com.example.zennapp.data.AppContainer
import com.example.zennapp.data.DefaultAppContainer
import kotlinx.serialization.Serializable
@Suppress("PLUGIN_IS_NOT_ENABLED")
@Serializable
class ZennArticlesApplication: Application() {
lateinit var container: AppContainer
override fun onCreate() {
super.onCreate()
container = DefaultAppContainer()
}
}
最後に MainActivity
で以下のように ZennAppScaffold
を指定することで、記事の一覧を取得するアプリを表示できるようになるかと思います。
MainActivity
package com.example.zennapp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.example.zennapp.ui.theme.ZennAppTheme
import com.example.zennapp.ui.theme.screens.ZennAppScaffold
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ZennAppTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
ZennAppScaffold()
}
}
}
}
}
これで以下の動画のように記事を取得、表示するアプリが実装できるかと思います。
まとめ
最後まで読んでいただいてありがとうございました。
今回は公式ドキュメントで実装した内容を定着させるために、記事取得アプリを実装してみました。
今回は GET メソッドでデータを取得するのみでしたが、別の実装をする機会があればその都度まとめられたらと思います。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。
参考
Discussion