既存のアプリにJetpack Composeを適用する

Pocket

既存のアプリにJetpack Composeを適用する

はじめに

以前、Jetpack Composeを使ってみる という記事を書きました。

その続編です。


目的

タイトル通りですが、既にあるアプリにJetpack Compseを適用することを目的とします。


サンプルコードの紹介

Android公式のJetpack ComposeのサンプルコードにJetNewsがあります。
Android Stduioでのサンプルコードの取得方法はこちら
JetNewsを参考に既存のアプリにJetpack Compseを適用しましょう。


準備

まずは既存のアプリを準備しましょう。
※ Android 4.2 Canary5を使用しています。
Android Studioのメニューから新しいプロジェクトを作成してください。
その際、Basic Activityを選択してください。

出来上がったプロジェクトは以下のような画面構成になります。
navi_graph.xml のスクリーンショットです。

FirstFragmentでNEXTボタンを押下すると、SecondFragmentに遷移し、
SecondFragmentでPREVIOUSでFirstFragmentに遷移します。
このアプリにJetpack Composeを適用していきます。


Jetpack Composeライブラリを適用する

まずはbuild.gradleの設定です。

android {
    compileSdkVersion 30
    buildToolsVersion "29.0.3"

    defaultConfig {
        applicationId "opst.co.jp.jetpackfragmentsample"
        minSdkVersion 23
        targetSdkVersion 30
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildFeatures {
        // Enables Jetpack Compose for this module
        compose true
    }

    ...

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }

    kotlinOptions {
        jvmTarget = '1.8'
    }

    composeOptions {
        kotlinCompilerExtensionVersion "0.1.0-dev14"
    }
}

dependencies {

    ...
    implementation 'androidx.ui:ui-core:0.1.0-dev14'
    implementation 'androidx.ui:ui-tooling:0.1.0-dev14'
    implementation 'androidx.ui:ui-layout:0.1.0-dev14'
    implementation 'androidx.ui:ui-material:0.1.0-dev14'
    ...
}


各FragmentをComposableで作成する

FirstFragment、SecondFragmentをそれぞれComposableで置き換えましょう。
AppBarのアイコンと、FloatingActionButtonのアイコンが違うのはここでは放置します。

FirstFragmentは以下のようにします。

@Composable
fun FirstScreen(scaffoldState: ScaffoldState = remember { ScaffoldState()}) {
    Scaffold(
        scaffoldState = scaffoldState,
        floatingActionButton = {
            Button(onClick = {} ) {
            }
        },
        drawerContent = {
        },
        topBar = {
            TopAppBar(
                title = { Text(text = "jetpackFragmentSample") }
            )
        },
        bodyContent = { innerPadding ->
            FirstScreenContent()
        }
    )

}

@Composable
fun FirstScreenContent() {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.SpaceEvenly,
        horizontalGravity = Alignment.CenterHorizontally
    ) {
        Text(
            text = "Hello first fragment",
            modifier = Modifier.fillMaxWidth(),
            textAlign = TextAlign.Center,
            fontSize = TextUnit.Companion.Sp(14)
        )
        Button(
            onClick = {}) {
            Text(text = "NEXT")
        }
    }
}

@Preview
@Composable
fun FirstScreenPreview() {
    FirstScreen()
}

SecondFragmentは以下のようにします。

@Composable
fun SecondScreen(scaffoldState: ScaffoldState = remember { ScaffoldState() }) {
    Scaffold(
        scaffoldState = scaffoldState,
        floatingActionButton = {
            Button(onClick = {}) {

            }
        },
        drawerContent = {
        },
        topBar = {
            TopAppBar(
                title = { Text(text = "jetpackFragmentSample") }
            )
        },
        bodyContent = { innerPadding ->
            SecondScreenContent()
        }
    )

}

@Composable
fun SecondScreenContent() {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.SpaceEvenly,
        horizontalGravity = Alignment.CenterHorizontally
    ) {
        Text(
            text = "",
            modifier = Modifier.fillMaxWidth(),
            textAlign = TextAlign.Center)
        Button(
            onClick = {}) {
            Text(text = "PREVIOUS")
        }
    }
}

@Preview
@Composable
fun SecondScreenPreview() {
    SecondScreen()
}

これで画面はできましたが、まだ遷移はできません。


Fragmentの遷移

既存アプリでは、FirstFragmentとSecondFragment間の遷移はNavigation Componentが使われています。
JetNews Sampleでは画面の遷移をComposable Functionを差し替えていくことで実現しています。
以下がその部分のコードです。
navigationViewModel.currentScreen が変更されると、HomeScreen、InterestsScreen、ArticleScreenにそれぞれ遷移します。

class MainActivity : AppCompatActivity() {

    val navigationViewModel by viewModels<NavigationViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val appContainer = (application as JetnewsApplication).container
        setContent {
            JetnewsApp(appContainer, navigationViewModel)
        }
    }

    override fun onBackPressed() {
        if (!navigationViewModel.onBack()) {
            super.onBackPressed()
        }
    }
}
@Composable
fun JetnewsApp(
    appContainer: AppContainer,
    navigationViewModel: NavigationViewModel
) {
    JetnewsTheme {
        AppContent(
            navigationViewModel = navigationViewModel,
            interestsRepository = appContainer.interestsRepository,
            postsRepository = appContainer.postsRepository
        )
    }
}

@Composable
private fun AppContent(
    navigationViewModel: NavigationViewModel,
    postsRepository: PostsRepository,
    interestsRepository: InterestsRepository
) {
    Crossfade(navigationViewModel.currentScreen) { screen ->
        Surface(color = MaterialTheme.colors.background) {
            when (screen) {
                is Screen.Home -> HomeScreen(
                    navigateTo = navigationViewModel::navigateTo,
                    postsRepository = postsRepository
                )
                is Screen.Interests -> InterestsScreen(
                    navigateTo = navigationViewModel::navigateTo,
                    interestsRepository = interestsRepository
                )
                is Screen.Article -> ArticleScreen(
                    postId = screen.postId,
                    postsRepository = postsRepository,
                    onBack = { navigationViewModel.onBack() }
                )
            }
        }
    }
}

このソースを参考にしながら、画面遷移を作成します。
まず、JetNewsSampleのnavigation.ktを流用して以下のようにします。
また、SavedStateHandleUtils.ktはそのまま流用します。
※ 見辛くなるのでコメントは消してあります

enum class ScreenName { FIRST, SECOND}

sealed class Screen(val id: ScreenName) {
    object First : Screen(ScreenName.FIRST)
    object Second : Screen(ScreenName.SECOND)
}

private const val SIS_SCREEN = "sis_screen"
private const val SIS_NAME = "screen_name"

private fun Screen.toBundle(): Bundle {
    return bundleOf(SIS_NAME to id.name)
}

private fun Bundle.toScreen(): Screen {
    val screenName = ScreenName.valueOf(getStringOrThrow(SIS_NAME))
    return when (screenName) {
        ScreenName.FIRST -> Screen.First
        ScreenName.SECOND -> Screen.Second
    }
}

private fun Bundle.getStringOrThrow(key: String) =
    requireNotNull(getString(key)) { "Missing key '$key' in $this" }

class NavigationViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    var currentScreen: Screen by savedStateHandle.getMutableStateOf<Screen>(
        key = SIS_SCREEN,
        default = Screen.First,
        save = { it.toBundle() },
        restore = { it.toScreen() }
    )
        private set // limit the writes to only inside this class.

    @MainThread
    fun onBack(): Boolean {
        val wasHandled = currentScreen != Screen.First
        currentScreen = Screen.First
        return wasHandled
    }

    @MainThread
    fun navigateTo(screen: Screen) {
        currentScreen = screen
    }
}

画面遷移部分を実装する

currentScreenが変更されたら画面を切り替える部分を実装しましょう。
FirstScreen,SecondScreenを実際に切り替えるところは以下のようになります。

@Composable
fun jetpackFragmentSample(navigationViewModel: NavigationViewModel) {
    MaterialTheme {
        Surface(color = MaterialTheme.colors.background) {
            when (navigationViewModel.currentScreen) {
                Screen.First -> FirstScreen(navigateTo = navigationViewModel::navigateTo)
                Screen.Second -> SecondScreen(navigateTo = navigationViewModel::navigateTo)
            }
        }
    }
}

これに合わせて、FirstScreen,SecondScreenのボタンが押されたら、currentScreenを切り替える処理を追加しましょう。

@Composable
fun FirstScreen(
    navigateTo: (Screen) -> Unit,
    scaffoldState: ScaffoldState = remember { ScaffoldState() }
) {
    Scaffold(
        scaffoldState = scaffoldState,
        floatingActionButton = {
            Button(onClick = {}) {
            }
        },
        drawerContent = {
        },
        topBar = {
            TopAppBar(
                title = { Text(text = "jetpackFragmentSample") }
            )
        },
        bodyContent = { innerPadding ->
            FirstScreenContent(navigateTo)
        }
    )

}

@Composable
fun FirstScreenContent(
    navigateTo: (Screen) -> Unit
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.SpaceEvenly,
        horizontalGravity = Alignment.CenterHorizontally
    ) {
        Text(
            text = "Hello first fragment",
            modifier = Modifier.fillMaxWidth(),
            textAlign = TextAlign.Center,
            fontSize = TextUnit.Companion.Sp(14)
        )
        Button(
            onClick = {
                navigateTo(Screen.Second)
            }) {
            Text(text = "NEXT")
        }
    }
}

@Preview
@Composable
fun FirstScreenPreview() {
    FirstScreen(navigateTo = { })
}
@Composable
fun SecondScreen(
    navigateTo: (Screen) -> Unit,
    scaffoldState: ScaffoldState = remember { ScaffoldState() }
) {
    Scaffold(
        scaffoldState = scaffoldState,
        floatingActionButton = {
            Button(onClick = {}) {
            }
        },
        drawerContent = {
        },
        topBar = {
            TopAppBar(
                title = { Text(text = "jetpackFragmentSample") }
            )
        },
        bodyContent = { innerPadding ->
            SecondScreenContent(navigateTo = navigateTo)
        }
    )

}

@Composable
fun SecondScreenContent(
    navigateTo: (Screen) -> Unit
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.SpaceEvenly,
        horizontalGravity = Alignment.CenterHorizontally
    ) {
        Text(
            text = "",
            modifier = Modifier.fillMaxWidth(),
            textAlign = TextAlign.Center
        )
        Button(
            onClick = {
                navigateTo(Screen.First)
            }) {
            Text(text = "PREVIOUS")
        }
    }
}

@Preview
@Composable
fun SecondScreenPreview() {
    SecondScreen(navigateTo = {})
}

MainActivityからsetContent

あとはMainActivityからsetContentするだけです。

class MainActivity : AppCompatActivity() {

    val navigationViewModel by viewModels<NavigationViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            jetpackFragmentSample(navigationViewModel)
        }
    }
}

これでボタンを押下するたびに画面が切り替わるようになります。


課題

以下の様な課題があるかと思います。

  • 画面遷移部分はNavigation Componentと親和性がない
  • バックスタックの管理など、画面遷移の状態管理をしなければならない

もしかすると、各FragmentでsetContentして画面遷移はNavigation Componentに任せるのが正解かもしれません。
また何かわかったら書きます。

Pocket

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です