この記事の内容
他のアプリ(Chromeとかマップとか)で共有を選んだときに、自分のアプリでその情報を受信するアプリを作ります。
Compose, Navigationを使っていきます。
前提
NavigationとComposeの依存関係を抜粋しました。
// compose
val composeVersion = "1.4.3"
implementation("androidx.activity:activity-compose:1.7.2")
implementation("androidx.compose.ui:ui:$composeVersion")
implementation("androidx.compose.ui:ui-graphics:$composeVersion")
implementation("androidx.compose.ui:ui-tooling-preview:$composeVersion")
implementation("androidx.compose.foundation:foundation:$composeVersion")
implementation("androidx.compose.material:material-icons-extended:$composeVersion")
implementation("androidx.compose.material3:material3:1.1.1")
// depend on it to compile against version 34 or later of the Android APIs.となるので、Gradleが34対応ができるまでは古いバージョンを使う。
// implementation("androidx.navigation:navigation-compose:2.7.0-rc01")
//noinspection GradleDependency
implementation("androidx.navigation:navigation-compose:2.7.0-beta01")
gradleの全文が気になる方は展開してください
plugins {
id("com.android.application") version "8.1.0" apply false
id("org.jetbrains.kotlin.android") version "1.8.10" apply false
id("com.google.gms.google-services") version "4.3.15" apply false
id("com.google.dagger.hilt.android") version "2.47" apply false
id("com.google.devtools.ksp") version "1.8.10-1.0.9" apply false
}
```
```gradle:app/build.gradle
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
id("com.google.gms.google-services")
id("com.google.dagger.hilt.android")
}
android {
namespace = "com.jozu.compose.planfun"
compileSdk = 33
val localProperties = gradleLocalProperties(rootDir)
val googleOauthServerClientId = localProperties.getProperty("google_oauth_server_client_id")
buildFeatures {
buildConfig = true
}
defaultConfig {
applicationId = "com.jozu.compose.planfun"
minSdk = 26
// AGP roadmap https://developer.android.com/studio/releases/gradle-plugin-roadmap?hl=ja
//noinspection OldTargetApi
targetSdk = 33
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
buildConfigField("String", "GOOGLE_OAUTH_SERVER_CLIENT_ID", googleOauthServerClientId)
}
signingConfigs {
getByName("debug") {
storeFile = file(project.rootProject.file(localProperties.getProperty("keystore.file")))
storePassword = localProperties.getProperty("keystore.storePassword")
keyAlias = localProperties.getProperty("keystore.alias")
keyPassword = localProperties.getProperty("keystore.keyPassword")
}
create("release") {
storeFile = file(project.rootProject.file(localProperties.getProperty("keystore.file")))
storePassword = localProperties.getProperty("keystore.storePassword")
keyAlias = localProperties.getProperty("keystore.alias")
keyPassword = localProperties.getProperty("keystore.keyPassword")
}
}
buildTypes {
debug {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("debug")
}
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.majorVersion
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.4.3"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation("com.google.android.material:material:1.9.0")
// compose
val composeVersion = "1.4.3"
implementation("androidx.activity:activity-compose:1.7.2")
implementation("androidx.compose.ui:ui:$composeVersion")
implementation("androidx.compose.ui:ui-graphics:$composeVersion")
implementation("androidx.compose.ui:ui-tooling-preview:$composeVersion")
implementation("androidx.compose.foundation:foundation:$composeVersion")
implementation("androidx.compose.material:material-icons-extended:$composeVersion")
implementation("androidx.compose.material3:material3:1.1.1")
// depend on it to compile against version 34 or later of the Android APIs.となるので、Gradleが34対応ができるまでは古いバージョンを使う。
// implementation("androidx.navigation:navigation-compose:2.7.0-rc01")
//noinspection GradleDependency
implementation("androidx.navigation:navigation-compose:2.7.0-beta01")
// permission
implementation("com.google.accompanist:accompanist-permissions:0.30.1")
// coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.2")
runtimeOnly("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
// logger
implementation("com.jakewharton.timber:timber:5.0.1")
// hilt
val hiltVersion = "2.47"
implementation("com.google.dagger:hilt-android:$hiltVersion")
ksp("com.google.dagger:hilt-android-compiler:$hiltVersion")
implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
// Firebase
implementation(platform("com.google.firebase:firebase-bom:32.2.0"))
implementation("com.google.firebase:firebase-analytics-ktx")
implementation("com.google.firebase:firebase-auth-ktx")
implementation("com.google.firebase:firebase-firestore-ktx")
implementation("com.google.firebase:firebase-storage-ktx")
// for GoogleSignIn
implementation("com.google.android.gms:play-services-auth:20.6.0")
// maps
implementation("com.google.maps.android:maps-compose:2.11.4")
implementation("com.google.android.gms:play-services-maps:18.1.0")
// coil
implementation("io.coil-kt:coil-compose:2.4.0")
// For Robolectric tests.
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.9")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.0.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4:$composeVersion")
debugImplementation("androidx.compose.ui:ui-tooling:$composeVersion")
debugImplementation("androidx.compose.ui:ui-test-manifest:$composeVersion")
}
モジュール構成
UI部分でのモジュール構成を以下の様に改造していきます。
改造前
クラス・compose | 役割 |
---|---|
MainActivity | PlanFunAppコンポーズをsetContentしているだけのActivityクラス |
PlanFunApp | ログイン、コンテンツ、Loadingなんかのメインコンテンツをしています。 |
改造後
クラス・compose | 役割 | 改造? |
---|---|---|
MainActivity | PlanFunAppコンポーズをsetContentしているだけのActivityクラス | 改造なし |
PlanFunApp | メインコンテンツと共有画面を切り替えるNavHostを表示する様にします | 改造あり |
AppMainScreen | メインコンテンツを表示するスクリーン | NEW |
ShareTargetScreen | 共有画面を表示するスクリーン | NEW |
AppNavHost | NavHostの実装 | NEW |
AndroidManifest
他のアプリで表示する共有シートに、自分のアプリが表示されるようにAndroidManifest.xmlに定義を追加します。他のアプリからMainActivityが呼ばれたいので、「android.intent.action.SEND」をMainActivityのIntentFilterに追加しています。
また、テキストの共有のときだけでいいので、「mimeType」は「"text/*"」としています。
それ以外の共有をしたいときは、公式サイトをご覧くださいませ。
<activity
android:name=".presentation.MainActivity"
android:exported="true"
android:theme="@style/AppTheme"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.SEND" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:mimeType="text/*" />
+ </intent-filter>
</activity>
MainActivity
Composeを使用しない場合は、Activityに共有情報の解析に関する実装が必要でした。
Composeを使用する場合は、Activityに何も実装はいらないので、Composeを呼び出すだけの実装です。
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { PlanFunApp() }
}
}
PlanFunApp
NavHostをアプリのテーマでラップしただけのComposeです。
@Composable
fun PlanFunApp() {
val navController = rememberNavController()
PlanFunTheme {
AppNavHost(navController)
}
}
AppNavHost
NavHostの定義のなかで、アプリの通常起動か、他アプリからの共有情報を持った起動かの判定をして、表示する画面を変えています。
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = AppNavRoute.Main.route,
) {
// ①通常起動時
composable(
route = AppNavRoute.Main.route,
) {
AppMainScreen()
}
// ②共有情報からの起動時
composable(
route = AppNavRoute.ShareTarget.route,
deepLinks = listOf(
navDeepLink {
action = Intent.ACTION_SEND
mimeType = "text/*"
},
),
) {
ShareTargetScreen()
}
}
}
①通常起動時
こちらは、メインコンテンツを表示するための記述になります。
②共有情報からの起動時
こちらが、他アプリから共有された時の起動になります。
navDeepLink {
action = Intent.ACTION_SEND
mimeType = "text/*"
}
共有情報はテキストのみを想定しているので、navDeepLinkは一つしかリストに入れていませんが、
テキストの他にも必要な場合は、この記述を2つ、3つとリストに入れていく形になります。
この通りに実装した場合、アプリが起動したあとNavigationのBackStackには、「AppNavRoute.Main.route」と「AppNavRoute.ShareTarget.route」の2つが積まれる状態になります。
つまり、共有情報を受けて起動した「ShareTargetScreen」で「戻る」処理が行われた時、アプリが終了するのではなく、「AppMainScreen」が起動します。
共有情報を受信するアプリを作る場合、共有情報の画面から戻る先は、共有情報を提供してきたアプリ(呼び出し元アプリ)が一般的ですので、この実装は私的にはかなり気持ち悪い感じがしました。
戻るボタンに対応したAppNavHost
上記の気持ち悪さを解消するために、NavHostを改造します。
よくあるNavigationを使用してログインとメインコンテンツの画面遷移があるようなアプリ構成では、画面遷移のイベント発生時に、ログインをBackStackから消して、メインコンテンツを表示するという処理をすると思います。
こんな感じですかね。(NavHostにrouteが指定されている前提で)
navController.navigate("遷移先ルート名") {
popUpTo(navController.graph.id) {
inclusive = true
}
}
ただ、今回のACTION_SENDに対応するnavigate()は、ライブラリ内で行われてしまっているので「popUpTo」のところを記述する隙がありません。。。苦肉の策で以下の様に実装してみました。(良案教えてください(・・)(..))
@Composable
fun AppNavHost(navController: NavHostController) {
+ navController.addOnDestinationChangedListener { controller, destination, arguments ->
+ // ShareTargetScreenへの遷移か?
+ if (destination.route == AppNavRoute.ShareTarget.route) {
+ // 遷移元はAppMainScreenか?
+ if (controller.previousBackStackEntry?.destination?.route == AppNavRoute.Main.route) {
+ // ShareTargetScreenへの遷移に対するResourceIdを取得する
+ val routeId = controller.graph.findNode(AppNavRoute.ShareTarget.route)?.id ?: return@addOnDestinationChangedListener
+
+ // BackStackをNavHostのルートまで戻す
+ val navOptions = navOptions {
+ popUpTo(navController.graph.id) {
+ inclusive = true
+ }
+ }
+
+ // ShareTargetScreenへACTION_SENDとかの呼び出し引数をつけた状態で遷移する
+ controller.navigate(
+ resId = routeId,
+ args = arguments,
+ navOptions = navOptions,
+ )
+ }
+ }
+ }
NavHost(
navController = navController,
startDestination = AppNavRoute.Main.route,
+ route = "app-nav",
) {
composable(
route = AppNavRoute.Main.route,
) {
AppMainScreen()
}
composable(
route = AppNavRoute.ShareTarget.route,
deepLinks = listOf(
navDeepLink {
action = Intent.ACTION_SEND
mimeType = "text/*"
},
),
) {
ShareTargetScreen()
}
}
}
「NavHostController」に「OnDestinationChangedListener」を追加して、「AppMainScreen」から「ShareTargetScreen」の遷移の場合は、BackStackを「NavHostのルート」まで戻した後に、改めて「ShareTargetScreen」へ遷移するという記述を足しています。
ShareTargetScreen
起動時引数(共有情報の取得)はViewModelで行いますので、とりあえず表示だけです。
sealed class SharedContent<String> {
object Proceeding : SharedContent<String>()
data class Analyzed(val result: String) : SharedContent<String>()
data class Error(val cause: Throwable) : SharedContent<String>()
}
@Composable
fun ShareTargetScreen(viewModel: ShareTargetViewModel = hiltViewModel()) {
// ViewModelから共有情報を取得する
val sharedContentState: State<SharedContent<String>> = viewModel.sharedContentState.collectAsState()
when (val sharedContent = sharedContentState.value) {
is SharedContent.Proceeding -> {
Text("Shared-Target Proceeding...")
}
is SharedContent.Analyzed -> {
Text("Analyzed!! ${sharedContent.result}")
}
is SharedContent.Error -> {
Text("Error!! ${sharedContent.cause.localizedMessage}")
}
}
}
ShareTargetViewModel
@HiltViewModel
class ShareTargetViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
sharedAnalyzeUseCase: SharedAnalyzeUseCase,
) : ViewModel() {
val sharedContentState: StateFlow<SharedContent<String>> = savedStateHandle.getStateFlow(NavController.KEY_DEEP_LINK_INTENT, Intent())
.map { intent: Intent -> sharedAnalyzeUseCase.invoke(intent) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = SharedContent.Proceeding
)
}
Hiltを使用していれば、
savedStateHandle: SavedStateHandle,
は自動でInjectされます。
このsavedStateHandleから「NavController.KEY_DEEP_LINK_INTENT」を取得すると、共有情報を取得することができます。
SharedAnalyzeUseCase
共有情報を含むIntentから送信されてきたテキストを取得する部分の記述です。
これは、従来の方法(Composeを使っていないとき)と同じ記述となります。
class SharedAnalyzeUseCase @Inject constructor(
private val spotRepository: SpotRepository,
) {
fun invoke(intent: Intent): SharedContent<String> {
if (intent.action != Intent.ACTION_SEND) {
return SharedContent.Error(IllegalArgumentException("action is not action_send. (${intent.action})"))
}
return if (intent.type?.startsWith("text/") == true) {
val textContent = intent.getStringExtra(Intent.EXTRA_TEXT) ?: ""
SharedContent.Analyzed(textContent)
} else {
return SharedContent.Error(IllegalArgumentException("type is not text. (${intent.type})"))
}
}
}
完成
上述のソースを含む全ソースはこちらです。
もっと素敵なやり方をご存じでしたら、ご一報ください!!