diff --git a/.github/android_example.gif b/.github/android_example.gif new file mode 100644 index 0000000..64109bb Binary files /dev/null and b/.github/android_example.gif differ diff --git a/.github/green-stash.gif b/.github/green-stash.gif deleted file mode 100644 index 02e01ae..0000000 Binary files a/.github/green-stash.gif and /dev/null differ diff --git a/.github/ios_example.gif b/.github/ios_example.gif new file mode 100644 index 0000000..8536a62 Binary files /dev/null and b/.github/ios_example.gif differ diff --git a/.github/tap-target-image.gif b/.github/tap-target-image.gif deleted file mode 100644 index d69f7ab..0000000 Binary files a/.github/tap-target-image.gif and /dev/null differ diff --git a/.gitignore b/.gitignore index d533403..ea6934c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ local.properties .idea/* !.idea/copyright .DS_Store -/build +build /captures .externalNativeBuild ktlint \ No newline at end of file diff --git a/README.md b/README.md index d510cf2..05c948b 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,31 @@ -#### \ - -#### [:mega: Use AI to manage your app's reviews on Google Play :arrow_right:](https://playstorereply.com) -Increase your apps ratings. Engage with all users, no more unanswered reviews. -Save time and increase customer satisfaction. - -#### \ - -# tap-target-compose -tap-target-compose is a Jetpack Compose implementation of the [Material Design tap targets](https://m1.material.io/growth-communications/feature-discovery.html#feature-discovery-design), used for feature discovery. +# tap-target-cmp +tap-target-compose is a Compose Multiplatform implementation of the [Material Design tap targets](https://m1.material.io/growth-communications/feature-discovery.html#feature-discovery-design), used for feature discovery. This library was inspired by its View counterpart, [TapTargetView](https://github.com/KeepSafe/TapTargetView). -| | | -| --- | --- | -| ![](/.github/tap-target-image.gif) | [![](/.github/green-stash.gif)](https://github.com/Pool-Of-Tears/GreenStash) | +| | | +|-----------------------------------|-------------------------------| +| ![](/.github/android_example.gif) | ![](/.github/ios_example.gif) | # Sample app This library comes with a sample app that shows examples of how to use it. -* [Click here to see the source code of the sample app](./sample-app/). +* [Click here to see the source code of the example](./example/). -:eyes: If you want to know when a new release of the library is published: [watch this repository on GitHub](https://github.com/PierfrancescoSoffritti/tap-target-compose/watchers). +:eyes: If you want to know when a new release of the library is published: [watch this repository on GitHub](https://github.com/ienground/tap-target-cmp/watchers). # Download -The Gradle dependency is available via [MavenCentral](https://repo1.maven.org/maven2/com/pierfrancescosoffritti/). - -The minimum API level supported by this library is API 13. +The minimum API level supported by this library is API 29. Add this to your module level `build.gradle` file to start using the library. +```toml +taptarget = { group = "zone.ien.taptargetcmp", name = "taptarget", version = "1.2.1" } +``` + ```gradle dependencies { - implementation "com.pierfrancescosoffritti.taptargetcompose:core:1.2.1" + implementation(libs.taptarget) } ``` @@ -108,4 +102,4 @@ The library supports chaining of multiple tap targets, but you can also show onl --- -For any question feel free to [open an issue on the GitHub repository](https://github.com/PierfrancescoSoffritti/tap-target-compose/issues). \ No newline at end of file +For any question feel free to [open an issue on the GitHub repository](https://github.com/ienground/tap-target-cmp/issues). \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 8753165..d784a54 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,11 +1,57 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - alias(libs.plugins.android.application) apply false - alias(libs.plugins.jetbrains.kotlin.android) apply false - alias(libs.plugins.android.library) apply false - alias(libs.plugins.gradleNexus.publish) - alias(libs.plugins.jetbrains.dokka) + alias(libs.plugins.android.kotlin.multiplatform.library) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.serialization) apply false + alias(libs.plugins.vanniktech.mavenPublish) apply false + alias(libs.plugins.android.lint) apply false alias(libs.plugins.compose.compiler) apply false } +val isReleaseVersion = !version.toString().endsWith("SNAPSHOT") -apply(from = "./scripts/publish-root.gradle") \ No newline at end of file +subprojects { + plugins.withId("com.vanniktech.maven.publish") { + configure { + publishToMavenCentral(automaticRelease = true) + + // Artifact ID만 각 프로젝트의 이름으로 자동 설정 + group = "zone.ien.taptargetcmp" + version = libs.versions.lib.version.name.get() + println("${group} ${project.name} ${version}") + + coordinates(group.toString(), project.name, version.toString()) + + pom { + name = project.name + description = "A Compose Multiplatform implementation of Material Design tap targets, for feature discovery." + inceptionYear = "2026" + url = "https://github.com/ienground/tap-target-cmp" + licenses { + license { + name = "Apache-2.0" + url = "https://www.apache.org/licenses/LICENSE-2.0" + } + } + developers { + developer { + id = "ienground" + name = "Ericano Rhee" + url = "https://www.ien.zone" + } + } + scm { + url = "https://github.com/ienground/tap-target-cmp.git" + connection = "scm:git:https://github.com/ienground/tap-target-cmp.git" + developerConnection = "scm:git:https://github.com/ienground/tap-target-cmp.git" + } + } + + val isPublishingToMavenLocal = gradle.startParameter.taskNames.any { it.contains("publishToMavenLocal", ignoreCase = true) } + val isSnapshot = version.toString().endsWith("SNAPSHOT") + + if (!isSnapshot && !isPublishingToMavenLocal) { + signAllPublications() + } + } + } +} \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts deleted file mode 100644 index f55cb6b..0000000 --- a/buildSrc/build.gradle.kts +++ /dev/null @@ -1,7 +0,0 @@ -plugins { - `kotlin-dsl` -} - -repositories { - mavenCentral() -} \ No newline at end of file diff --git a/buildSrc/src/main/java/com/psoffritti/taptargetcompose/Configuration.kt b/buildSrc/src/main/java/com/psoffritti/taptargetcompose/Configuration.kt deleted file mode 100644 index f0d9a71..0000000 --- a/buildSrc/src/main/java/com/psoffritti/taptargetcompose/Configuration.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.psoffritti.taptargetcompose - -object Configuration { - const val compileSdk = 35 - const val targetSdk = 35 - const val minSdk = 13 - const val minSdkSampleApp = 21 - private const val majorVersion = 1 - private const val minorVersion = 2 - private const val patchVersion = 1 - const val versionCode = 4 - const val versionName = "$majorVersion.$minorVersion.$patchVersion" - const val snapshotVersionName = "$majorVersion.$minorVersion.${patchVersion + 1}-SNAPSHOT" -} \ No newline at end of file diff --git a/example/androidApp/build.gradle.kts b/example/androidApp/build.gradle.kts new file mode 100644 index 0000000..42e75c4 --- /dev/null +++ b/example/androidApp/build.gradle.kts @@ -0,0 +1,49 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "zone.ien.taptargetcmp.example" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + applicationId = "zone.ien.taptargetcmp.example" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 1 + versionName = libs.versions.lib.version.name.get() + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} + +kotlin { + target { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + dependencies { + implementation(projects.example.composeApp) + implementation(libs.activity.compose) + implementation(libs.compose.ui.tooling) + } +} \ No newline at end of file diff --git a/sample-app/proguard-rules.pro b/example/androidApp/proguard-rules.pro similarity index 100% rename from sample-app/proguard-rules.pro rename to example/androidApp/proguard-rules.pro diff --git a/sample-app/src/main/AndroidManifest.xml b/example/androidApp/src/main/AndroidManifest.xml similarity index 100% rename from sample-app/src/main/AndroidManifest.xml rename to example/androidApp/src/main/AndroidManifest.xml diff --git a/example/androidApp/src/main/kotlin/zone/ien/taptargetcmp/example/MainActivity.kt b/example/androidApp/src/main/kotlin/zone/ien/taptargetcmp/example/MainActivity.kt new file mode 100644 index 0000000..e2184da --- /dev/null +++ b/example/androidApp/src/main/kotlin/zone/ien/taptargetcmp/example/MainActivity.kt @@ -0,0 +1,24 @@ +package zone.ien.taptargetcmp.example + +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: android.os.Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + enableEdgeToEdge() + App() + } + } +} + +@Preview +@Composable +fun AppAndroidPreview() { + App() +} \ No newline at end of file diff --git a/sample-app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/example/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml similarity index 100% rename from sample-app/src/main/res/drawable-v24/ic_launcher_foreground.xml rename to example/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml diff --git a/sample-app/src/main/res/drawable/ic_launcher_background.xml b/example/androidApp/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from sample-app/src/main/res/drawable/ic_launcher_background.xml rename to example/androidApp/src/main/res/drawable/ic_launcher_background.xml diff --git a/sample-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/example/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from sample-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to example/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/sample-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/example/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from sample-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to example/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/sample-app/src/main/res/mipmap-hdpi/ic_launcher.webp b/example/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp similarity index 100% rename from sample-app/src/main/res/mipmap-hdpi/ic_launcher.webp rename to example/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp diff --git a/sample-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/example/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp similarity index 100% rename from sample-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp rename to example/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp diff --git a/sample-app/src/main/res/mipmap-mdpi/ic_launcher.webp b/example/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp similarity index 100% rename from sample-app/src/main/res/mipmap-mdpi/ic_launcher.webp rename to example/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp diff --git a/sample-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/example/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp similarity index 100% rename from sample-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp rename to example/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp diff --git a/sample-app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/example/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp similarity index 100% rename from sample-app/src/main/res/mipmap-xhdpi/ic_launcher.webp rename to example/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp diff --git a/sample-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/example/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp similarity index 100% rename from sample-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp rename to example/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp diff --git a/sample-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/example/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp similarity index 100% rename from sample-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp rename to example/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp diff --git a/sample-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/example/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp similarity index 100% rename from sample-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp rename to example/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp diff --git a/sample-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/example/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp similarity index 100% rename from sample-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp rename to example/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp diff --git a/sample-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/example/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp similarity index 100% rename from sample-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp rename to example/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp diff --git a/sample-app/src/main/res/values/strings.xml b/example/androidApp/src/main/res/values/strings.xml similarity index 100% rename from sample-app/src/main/res/values/strings.xml rename to example/androidApp/src/main/res/values/strings.xml diff --git a/sample-app/src/main/res/values/themes.xml b/example/androidApp/src/main/res/values/themes.xml similarity index 100% rename from sample-app/src/main/res/values/themes.xml rename to example/androidApp/src/main/res/values/themes.xml diff --git a/example/composeApp/build.gradle.kts b/example/composeApp/build.gradle.kts new file mode 100644 index 0000000..7682fab --- /dev/null +++ b/example/composeApp/build.gradle.kts @@ -0,0 +1,50 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.serialization) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + androidLibrary { + namespace = "zone.ien.taptargetcmp.example.lib" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + + androidResources { + enable = true + } + } + + listOf( + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "ComposeApp" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + implementation(libs.compose.material3) + implementation(libs.compose.preview) + implementation(projects.taptarget) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + } +} + +dependencies { + androidRuntimeClasspath(libs.compose.ui.tooling) +} \ No newline at end of file diff --git a/example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/App.kt b/example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/App.kt new file mode 100644 index 0000000..bd01ffa --- /dev/null +++ b/example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/App.kt @@ -0,0 +1,171 @@ +package zone.ien.taptargetcmp.example + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import zone.ien.taptargetcmp.TapTargetCoordinator +import zone.ien.taptargetcmp.TapTargetDefinition +import zone.ien.taptargetcmp.TapTargetScope +import zone.ien.taptargetcmp.TapTargetStyle +import zone.ien.taptargetcmp.TextDefinition +import zone.ien.taptargetcmp.example.icon.Add +import zone.ien.taptargetcmp.example.icon.MaterialIcons +import zone.ien.taptargetcmp.example.ui.theme.AppTheme + +@Composable +fun App() { + AppTheme { + TapTargetCoordinator(showTapTargets = true, onComplete = {}) { + Surface(modifier = Modifier.fillMaxSize()) { + Content() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TapTargetScope.Content() { + val toolbarTapTarget = getStandardTapTargetDefinition( + precedence = 1, + title = "Toolbar tap target", + description = "This is a toolbar, tap it!", + ) + + val tab2TapTarget = getStandardTapTargetDefinition( + precedence = 2, + title = "Tab2 tap target", + description = "A moving target", + ) + + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + TopAppBar(title = { + Text(text = "Tap Target", modifier = Modifier.tapTarget(toolbarTapTarget)) + }) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { }, + icon = { Icon(MaterialIcons.Add, contentDescription = null) }, + text = { Text(text = "Click here") }, + modifier = Modifier.tapTarget( + getStandardTapTargetDefinition( + precedence = 0, + title = "Button tap target", + description = "This is a button, tap it!", + ) + ), + ) + }, + floatingActionButtonPosition = FabPosition.End, + ) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding)) { + TabbedControls(onTabSelected = {}) { tab -> + when (tab) { + Tab.TAB1 -> Text("Tab 1 content") + Tab.TAB2 -> Text("Tab 2 content", modifier = Modifier.tapTarget(tab2TapTarget)) + } + } + } + } +} + +private enum class Tab { + TAB1, TAB2; +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TabbedControls( + onTabSelected: (Tab) -> Unit, + modifier: Modifier = Modifier, + contentProvider: @Composable (Tab) -> Unit +) { + val scope = rememberCoroutineScope() + + val pagerState = rememberPagerState(pageCount = { Tab.entries.size }) + val tabTitles = Tab.entries.map { + when (it) { + Tab.TAB1 -> "Tab1" + Tab.TAB2 -> "Tab2" + } + } + + Column(modifier = modifier) { + TabRow(selectedTabIndex = pagerState.currentPage) { + tabTitles.forEachIndexed { index, title -> + Tab( + selected = index == pagerState.currentPage, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, + text = { Text(title) } + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + HorizontalPager(state = pagerState, modifier = Modifier.height(128.dp)) { pageIndex -> + contentProvider(Tab.entries[pageIndex]) + } + + LaunchedEffect(pagerState.settledPage) { + // Trigger the callback each time settledPage changes. + onTabSelected(Tab.entries[pagerState.settledPage]) + } + } +} + +@Composable +private fun getStandardTapTargetDefinition( + precedence: Int, + title: String, + description: String, + onTargetCLick: () -> Unit = {}, + onTargetCancel: () -> Unit = {}, +): TapTargetDefinition { + return TapTargetDefinition( + precedence = precedence, + title = TextDefinition( + text = title, + textStyle = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer + ), + description = TextDefinition( + text = description, + textStyle = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ), + tapTargetStyle = TapTargetStyle( + backgroundColor = MaterialTheme.colorScheme.secondaryContainer, + tapTargetHighlightColor = MaterialTheme.colorScheme.onSecondaryContainer, + backgroundAlpha = 1f, + ), + onTargetClick = onTargetCLick, + onTargetCancel = onTargetCancel, + ) +} \ No newline at end of file diff --git a/example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/icon/Add.kt b/example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/icon/Add.kt new file mode 100644 index 0000000..6120b13 --- /dev/null +++ b/example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/icon/Add.kt @@ -0,0 +1,55 @@ +package zone.ien.taptargetcmp.example.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val MaterialIcons.Add: ImageVector + get() { + if (_Add != null) { + return _Add!! + } + _Add = ImageVector.Builder( + name = "Add", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f + ).apply { + path(fill = SolidColor(Color(0xFF1F1F1F))) { + moveTo(440f, 520f) + lineTo(240f, 520f) + quadToRelative(-17f, 0f, -28.5f, -11.5f) + reflectiveQuadTo(200f, 480f) + quadToRelative(0f, -17f, 11.5f, -28.5f) + reflectiveQuadTo(240f, 440f) + horizontalLineToRelative(200f) + verticalLineToRelative(-200f) + quadToRelative(0f, -17f, 11.5f, -28.5f) + reflectiveQuadTo(480f, 200f) + quadToRelative(17f, 0f, 28.5f, 11.5f) + reflectiveQuadTo(520f, 240f) + verticalLineToRelative(200f) + horizontalLineToRelative(200f) + quadToRelative(17f, 0f, 28.5f, 11.5f) + reflectiveQuadTo(760f, 480f) + quadToRelative(0f, 17f, -11.5f, 28.5f) + reflectiveQuadTo(720f, 520f) + lineTo(520f, 520f) + verticalLineToRelative(200f) + quadToRelative(0f, 17f, -11.5f, 28.5f) + reflectiveQuadTo(480f, 760f) + quadToRelative(-17f, 0f, -28.5f, -11.5f) + reflectiveQuadTo(440f, 720f) + verticalLineToRelative(-200f) + close() + } + }.build() + + return _Add!! + } + +@Suppress("ObjectPropertyName") +private var _Add: ImageVector? = null diff --git a/example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/icon/MaterialIcons.kt b/example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/icon/MaterialIcons.kt new file mode 100644 index 0000000..5cd2d9d --- /dev/null +++ b/example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/icon/MaterialIcons.kt @@ -0,0 +1,3 @@ +package zone.ien.taptargetcmp.example.icon + +object MaterialIcons diff --git a/sample-app/src/main/java/com/psoffritti/taptargetcompose/ui/theme/Color.kt b/example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/ui/theme/Color.kt similarity index 98% rename from sample-app/src/main/java/com/psoffritti/taptargetcompose/ui/theme/Color.kt rename to example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/ui/theme/Color.kt index 1561045..5e5cb12 100644 --- a/sample-app/src/main/java/com/psoffritti/taptargetcompose/ui/theme/Color.kt +++ b/example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/ui/theme/Color.kt @@ -1,4 +1,4 @@ -package com.psoffritti.taptargetcompose.ui.theme +package zone.ien.taptargetcmp.example.ui.theme import androidx.compose.ui.graphics.Color diff --git a/sample-app/src/main/java/com/psoffritti/taptargetcompose/ui/theme/Theme.kt b/example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/ui/theme/Theme.kt similarity index 98% rename from sample-app/src/main/java/com/psoffritti/taptargetcompose/ui/theme/Theme.kt rename to example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/ui/theme/Theme.kt index 9a68d7e..dca9dd4 100644 --- a/sample-app/src/main/java/com/psoffritti/taptargetcompose/ui/theme/Theme.kt +++ b/example/composeApp/src/commonMain/kotlin/zone/ien/taptargetcmp/example/ui/theme/Theme.kt @@ -1,4 +1,4 @@ -package com.psoffritti.taptargetcompose.ui.theme +package zone.ien.taptargetcmp.example.ui.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme diff --git a/example/composeApp/src/iosMain/kotlin/zone/ien/taptargetcmp/example/MainViewController.kt b/example/composeApp/src/iosMain/kotlin/zone/ien/taptargetcmp/example/MainViewController.kt new file mode 100644 index 0000000..7f45e35 --- /dev/null +++ b/example/composeApp/src/iosMain/kotlin/zone/ien/taptargetcmp/example/MainViewController.kt @@ -0,0 +1,5 @@ +package zone.ien.taptargetcmp.example + +import androidx.compose.ui.window.ComposeUIViewController + +fun MainViewController() = ComposeUIViewController { App() } \ No newline at end of file diff --git a/example/iosApp/Configuration/Config.xcconfig b/example/iosApp/Configuration/Config.xcconfig new file mode 100644 index 0000000..7f4ce0b --- /dev/null +++ b/example/iosApp/Configuration/Config.xcconfig @@ -0,0 +1,7 @@ +TEAM_ID= + +PRODUCT_NAME=Taptarget +PRODUCT_BUNDLE_IDENTIFIER=zone.ien.taptargetcmp.example.Example$(TEAM_ID) + +CURRENT_PROJECT_VERSION=1 +MARKETING_VERSION=1.0 \ No newline at end of file diff --git a/example/iosApp/iosApp.xcodeproj/project.pbxproj b/example/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7e9110a --- /dev/null +++ b/example/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,375 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 3A70B7FFD2EF9475B39878DC /* Taptarget.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Taptarget.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + BF60BC51A4FC830ECF66E9A8 /* Exceptions for "iosApp" folder in "iosApp" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 451901D00D1CFF1EE0CD9FA3 /* iosApp */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 618D6E17DA4269CB0EBD4332 /* iosApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + BF60BC51A4FC830ECF66E9A8 /* Exceptions for "iosApp" folder in "iosApp" target */, + ); + path = iosApp; + sourceTree = ""; + }; + 7F32DC8847A2528DD5BA3D3C /* Configuration */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Configuration; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 4E041684C0A28B3842B9CDB1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9A4064F9F00428CC8A74D355 /* Products */ = { + isa = PBXGroup; + children = ( + 3A70B7FFD2EF9475B39878DC /* Taptarget.app */, + ); + name = Products; + sourceTree = ""; + }; + E0D5208EC197DC2AAFCBD5F8 = { + isa = PBXGroup; + children = ( + 7F32DC8847A2528DD5BA3D3C /* Configuration */, + 618D6E17DA4269CB0EBD4332 /* iosApp */, + 9A4064F9F00428CC8A74D355 /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 451901D00D1CFF1EE0CD9FA3 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = EDDB7722E65351F3212F695A /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + 413695130B836538C0F1B79E /* Compile Kotlin Framework */, + 7182ADBE3A18A5ED0EABA306 /* Sources */, + 4E041684C0A28B3842B9CDB1 /* Frameworks */, + ADE552C2797EC60E023FB327 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 618D6E17DA4269CB0EBD4332 /* iosApp */, + ); + name = iosApp; + packageProductDependencies = ( + ); + productName = iosApp; + productReference = 3A70B7FFD2EF9475B39878DC /* Taptarget.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A8E4552D1C27948C03A84D99 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1620; + LastUpgradeCheck = 1620; + TargetAttributes = { + 451901D00D1CFF1EE0CD9FA3 = { + CreatedOnToolsVersion = 16.2; + }; + }; + }; + buildConfigurationList = FCC04410EA9A0AD585F4BD99 /* Build configuration list for PBXProject "iosApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = E0D5208EC197DC2AAFCBD5F8; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 9A4064F9F00428CC8A74D355 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 451901D00D1CFF1EE0CD9FA3 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + ADE552C2797EC60E023FB327 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 413695130B836538C0F1B79E /* Compile Kotlin Framework */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Compile Kotlin Framework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n../gradlew :example:composeApp:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7182ADBE3A18A5ED0EABA306 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 1BC302D312A25FFCB2FC8457 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = M8ZHHMB2AV; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = zone.ien.taptargetcmp.example.Example; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 5540229B7DD869650AE56760 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 7F32DC8847A2528DD5BA3D3C /* Configuration */; + baseConfigurationReferenceRelativePath = Config.xcconfig; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 6D56266BAC5382354FF6611D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = M8ZHHMB2AV; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = zone.ien.taptargetcmp.example.Example; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + B703D85C6CA065A3541FC1D5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 7F32DC8847A2528DD5BA3D3C /* Configuration */; + baseConfigurationReferenceRelativePath = Config.xcconfig; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + EDDB7722E65351F3212F695A /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6D56266BAC5382354FF6611D /* Debug */, + 1BC302D312A25FFCB2FC8457 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FCC04410EA9A0AD585F4BD99 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B703D85C6CA065A3541FC1D5 /* Debug */, + 5540229B7DD869650AE56760 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A8E4552D1C27948C03A84D99 /* Project object */; +} diff --git a/example/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/example/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/ienground.xcuserdatad/UserInterfaceState.xcuserstate b/example/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/ienground.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..a1fd271 Binary files /dev/null and b/example/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/ienground.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/example/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/ienground.xcuserdatad/xcschemes/xcschememanagement.plist b/example/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/ienground.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..ee3458d --- /dev/null +++ b/example/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/ienground.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/example/iosApp/iosApp.xcodeproj/xcuserdata/ienground.xcuserdatad/xcschemes/iosApp.xcscheme b/example/iosApp/iosApp.xcodeproj/xcuserdata/ienground.xcuserdatad/xcschemes/iosApp.xcscheme new file mode 100644 index 0000000..743ecb8 --- /dev/null +++ b/example/iosApp/iosApp.xcodeproj/xcuserdata/ienground.xcuserdatad/xcschemes/iosApp.xcscheme @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + diff --git a/example/iosApp/iosApp.xcodeproj/xcuserdata/ienground.xcuserdatad/xcschemes/xcschememanagement.plist b/example/iosApp/iosApp.xcodeproj/xcuserdata/ienground.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..fa59f97 --- /dev/null +++ b/example/iosApp/iosApp.xcodeproj/xcuserdata/ienground.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + iosApp.xcscheme + + orderHint + 0 + + + + diff --git a/example/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/example/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/example/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/example/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..4e8d485 --- /dev/null +++ b/example/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "app-icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/example/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/example/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png new file mode 100644 index 0000000..53fc536 Binary files /dev/null and b/example/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png differ diff --git a/example/iosApp/iosApp/Assets.xcassets/Contents.json b/example/iosApp/iosApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/example/iosApp/iosApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/example/iosApp/iosApp/ContentView.swift b/example/iosApp/iosApp/ContentView.swift new file mode 100644 index 0000000..c765ff2 --- /dev/null +++ b/example/iosApp/iosApp/ContentView.swift @@ -0,0 +1,21 @@ +import UIKit +import SwiftUI +import ComposeApp + +struct ComposeView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + MainViewControllerKt.MainViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +struct ContentView: View { + var body: some View { + ComposeView() + .ignoresSafeArea() + } +} + + + diff --git a/example/iosApp/iosApp/Info.plist b/example/iosApp/iosApp/Info.plist new file mode 100644 index 0000000..11845e1 --- /dev/null +++ b/example/iosApp/iosApp/Info.plist @@ -0,0 +1,8 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + + diff --git a/example/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/example/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/example/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/example/iosApp/iosApp/iOSApp.swift b/example/iosApp/iosApp/iOSApp.swift new file mode 100644 index 0000000..b56461c --- /dev/null +++ b/example/iosApp/iosApp/iOSApp.swift @@ -0,0 +1,15 @@ +import SwiftUI +import ComposeApp + +@main +struct iOSApp: App { + init() { + + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 51a9535..9184c14 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,35 +1,53 @@ [versions] -compose-bom = "2024.10.00" -activity-compose = "1.9.3" -core-ktx = "1.13.1" -appcompat = "1.7.0" -material = "1.12.0" +agp = "9.0.0" +kotlin = "2.3.21" +android-minSdk = "29" +android-compileSdk = "36" +android-targetSdk = "36" +lib-version-name = "1.2.1" -androidGradlePlugin = "8.7.1" -androidLibrary = "8.7.1" -kotlinAndroid = "2.0.20" +# plugin +compose-plugin = "1.11.0-rc01" +compose-plugin-experimental = "1.11.0-alpha07" +compose-navigation3 = "1.1.1" -gradleNexus = "1.3.0" -dokka = "1.8.10" +# ui +activity-compose = "1.13.0" + +# functionality +androidx-core = "1.18.0" + +# backend +vanniktechMavenPublish = "0.36.0" + +# test +runner = "1.7.0" +coreVersion = "1.7.0" +junit = "1.3.0" [libraries] -androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } -androidx-compose-material3 = { module = "androidx.compose.material3:material3" } -androidx-compose-material-icons = { module = "androidx.compose.material:material-icons-extended" } -androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } -androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } -androidx-compose-animation = { module = "androidx.compose.animation:animation" } -androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } - -androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } -androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } -androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } -material = { module = "com.google.android.material:material", version.ref = "material" } +# ui +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } +compose-material3 = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "compose-plugin-experimental"} +compose-preview = { group = "org.jetbrains.compose.ui", name = "ui-tooling-preview", version.ref = "compose-plugin" } +compose-ui-tooling = { group = "org.jetbrains.compose.ui", name = "ui-tooling", version.ref = "compose-plugin" } +compose-resources = { group = "org.jetbrains.compose.components", name = "components-resources", version.ref = "compose-plugin" } + +# functionality +androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core"} # android only + +# test +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +runner = { group = "androidx.test", name = "runner", version.ref = "runner" } +core = { group = "androidx.test", name = "core", version.ref = "coreVersion" } +junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" } [plugins] -android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } -android-library = { id = "com.android.library", version.ref = "androidLibrary" } -jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinAndroid" } -gradleNexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "gradleNexus" } -jetbrains-dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } -compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinAndroid" } \ No newline at end of file +android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +android-application = { id = "com.android.application" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"} +vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" } +android-lint = { id = "com.android.lint", version.ref = "agp" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cd59683..61bcd03 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Fri Dec 08 17:09:33 GMT 2023 +#Thu May 07 22:10:16 KST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/sample-app/.gitignore b/sample-app/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/sample-app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/sample-app/build.gradle.kts b/sample-app/build.gradle.kts deleted file mode 100644 index 7539b48..0000000 --- a/sample-app/build.gradle.kts +++ /dev/null @@ -1,58 +0,0 @@ -import com.psoffritti.taptargetcompose.Configuration -import org.gradle.kotlin.dsl.android - -plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.jetbrains.kotlin.android) - alias(libs.plugins.compose.compiler) -} - -android { - namespace = "com.psoffritti.taptargetcompose" - compileSdk = Configuration.compileSdk - - defaultConfig { - applicationId = "com.psoffritti.taptargetcompose" - minSdk = Configuration.minSdkSampleApp - targetSdk = Configuration.targetSdk - versionCode = Configuration.versionCode - versionName = Configuration.versionName - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } - buildFeatures { - compose = true - } -} - -dependencies { - implementation(project(":tap-target-compose")) - - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.appcompat) - implementation(libs.material) - - val composeBom = platform(libs.androidx.compose.bom) - implementation(composeBom) - androidTestImplementation(composeBom) - implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.material.icons) - implementation(libs.androidx.activity.compose) - - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.ui.tooling) -} \ No newline at end of file diff --git a/sample-app/src/main/java/com/psoffritti/taptargetcompose/MainActivity.kt b/sample-app/src/main/java/com/psoffritti/taptargetcompose/MainActivity.kt deleted file mode 100644 index 29dfb1e..0000000 --- a/sample-app/src/main/java/com/psoffritti/taptargetcompose/MainActivity.kt +++ /dev/null @@ -1,175 +0,0 @@ -package com.psoffritti.taptargetcompose - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FabPosition -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.psoffritti.taptargetcompose.ui.theme.AppTheme -import kotlinx.coroutines.launch - -class MainActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - AppTheme { - TapTargetCoordinator(showTapTargets = true, onComplete = {}) { - Surface(modifier = Modifier.fillMaxSize()) { - Content() - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun TapTargetScope.Content() { - val toolbarTapTarget = getStandardTapTargetDefinition( - precedence = 1, - title = "Toolbar tap target", - description = "This is a toolbar, tap it!", - ) - - val tab2TapTarget = getStandardTapTargetDefinition( - precedence = 2, - title = "Tab2 tap target", - description = "A moving target", - ) - - Scaffold( - modifier = Modifier.fillMaxSize(), - containerColor = MaterialTheme.colorScheme.surface, - topBar = { - TopAppBar(title = { - Text(text = "Tap Target", modifier = Modifier.tapTarget(toolbarTapTarget)) - }) - }, - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { }, - icon = { Icon(Icons.Filled.Add, contentDescription = null) }, - text = { Text(text = "Click here") }, - modifier = Modifier.tapTarget( - getStandardTapTargetDefinition( - precedence = 0, - title = "Button tap target", - description = "This is a button, tap it!", - ) - ), - ) - }, - floatingActionButtonPosition = FabPosition.End, - ) { innerPadding -> - Column(modifier = Modifier.padding(innerPadding)) { - TabbedControls(onTabSelected = {}) { tab -> - when (tab) { - Tab.TAB1 -> Text("Tab 1 content") - Tab.TAB2 -> Text("Tab 2 content", modifier = Modifier.tapTarget(tab2TapTarget)) - } - } - } - } -} - -private enum class Tab { - TAB1, TAB2; -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun TabbedControls( - onTabSelected: (Tab) -> Unit, - modifier: Modifier = Modifier, - contentProvider: @Composable (Tab) -> Unit -) { - val scope = rememberCoroutineScope() - - val pagerState = rememberPagerState(pageCount = { Tab.entries.size }) - val tabTitles = Tab.entries.map { - when (it) { - Tab.TAB1 -> "Tab1" - Tab.TAB2 -> "Tab2" - } - } - - Column(modifier = modifier) { - TabRow(selectedTabIndex = pagerState.currentPage) { - tabTitles.forEachIndexed { index, title -> - Tab( - selected = index == pagerState.currentPage, - onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, - text = { Text(title) } - ) - } - } - Spacer(modifier = Modifier.height(8.dp)) - HorizontalPager(state = pagerState, modifier = Modifier.height(128.dp)) { pageIndex -> - contentProvider(Tab.entries[pageIndex]) - } - - LaunchedEffect(pagerState.settledPage) { - // Trigger the callback each time settledPage changes. - onTabSelected(Tab.entries[pagerState.settledPage]) - } - } -} - -@Composable -private fun getStandardTapTargetDefinition( - precedence: Int, - title: String, - description: String, - onTargetCLick: () -> Unit = {}, - onTargetCancel: () -> Unit = {}, -): TapTargetDefinition { - return TapTargetDefinition( - precedence = precedence, - title = TextDefinition( - text = title, - textStyle = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSecondaryContainer - ), - description = TextDefinition( - text = description, - textStyle = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer - ), - tapTargetStyle = TapTargetStyle( - backgroundColor = MaterialTheme.colorScheme.secondaryContainer, - tapTargetHighlightColor = MaterialTheme.colorScheme.onSecondaryContainer, - backgroundAlpha = 1f, - ), - onTargetClick = onTargetCLick, - onTargetCancel = onTargetCancel, - ) -} \ No newline at end of file diff --git a/scripts/publish-module.gradle b/scripts/publish-module.gradle deleted file mode 100644 index 7cf3ecd..0000000 --- a/scripts/publish-module.gradle +++ /dev/null @@ -1,84 +0,0 @@ -apply plugin: 'maven-publish' -apply plugin: 'signing' -apply plugin: 'org.jetbrains.dokka' - -tasks.register('androidSourcesJar', Jar) { - archiveClassifier.set('sources') - if (project.plugins.findPlugin("com.android.library")) { - from android.sourceSets.main.kotlin.srcDirs - } else { - from sourceSets.main.kotlin.srcDirs - } -} - -tasks.withType(dokkaHtmlPartial.getClass()).configureEach { - pluginsMapConfiguration.set( - ["org.jetbrains.dokka.base.DokkaBase": """{ "separateInheritedMembers": true}"""] - ) -} - -task javadocJar(type: Jar, dependsOn: dokkaJavadoc) { - archiveClassifier.set('javadoc') - from dokkaJavadoc.outputDirectory -} - -artifacts { - archives androidSourcesJar - archives javadocJar -} - -group = PUBLISH_GROUP_ID -version = PUBLISH_VERSION - -afterEvaluate { - publishing { - publications { - release(MavenPublication) { - tasks.named("generateMetadataFileForReleasePublication").configure { dependsOn("androidSourcesJar") } - groupId PUBLISH_GROUP_ID - artifactId PUBLISH_ARTIFACT_ID - version PUBLISH_VERSION - if (project.plugins.findPlugin("com.android.library")) { - from components.release - } else { - from components.java - } - - artifact javadocJar - - pom { - name = PUBLISH_ARTIFACT_ID - description = PUBLISH_DESCRIPTION - url = 'https://github.com/PierfrancescoSoffritti/tap-target-compose/' - licenses { - license { - name = 'MIT License' - url = 'https://github.com/PierfrancescoSoffritti/tap-target-compose/blob/main/LICENSE' - } - } - developers { - developer { - id = 'psoffritti' - name = 'Pierfrancesco Soffritti' - email = 'soffritti.pierfrancesco@gmail.com' - } - } - scm { - connection = 'scm:git:github.com/PierfrancescoSoffritti/tap-target-compose.git' - developerConnection = 'scm:git:ssh://github.com/PierfrancescoSoffritti/tap-target-compose.git' - url = 'https://github.com/PierfrancescoSoffritti/tap-target-compose.git' - } - } - } - } - } -} - -signing { - useInMemoryPgpKeys( - rootProject.ext["signing.keyId"], - rootProject.ext["signing.key"], - rootProject.ext["signing.password"], - ) - sign publishing.publications -} \ No newline at end of file diff --git a/scripts/publish-root.gradle b/scripts/publish-root.gradle deleted file mode 100644 index 7a8012c..0000000 --- a/scripts/publish-root.gradle +++ /dev/null @@ -1,48 +0,0 @@ -import com.psoffritti.taptargetcompose.Configuration - -// Create variables with empty default values -ext["ossrhUsername"] = '' -ext["ossrhPassword"] = '' -ext["sonatypeStagingProfileId"] = '' -ext["signing.keyId"] = '' -ext["signing.password"] = '' -ext["signing.key"] = '' -ext["snapshot"] = '' - -File secretPropsFile = project.rootProject.file('local.properties') -if (secretPropsFile.exists()) { - // Read local.properties file first if it exists - Properties p = new Properties() - new FileInputStream(secretPropsFile).withCloseable { is -> p.load(is) } - p.each { name, value -> ext[name] = value } -} else { - // Use system environment variables - ext["ossrhUsername"] = System.getenv('OSSRH_USERNAME') - ext["ossrhPassword"] = System.getenv('OSSRH_PASSWORD') - ext["sonatypeStagingProfileId"] = System.getenv('SONATYPE_STAGING_PROFILE_ID') - ext["signing.keyId"] = System.getenv('SIGNING_KEY_ID') - ext["signing.password"] = System.getenv('SIGNING_PASSWORD') - ext["signing.key"] = System.getenv('SIGNING_KEY') - ext["snapshot"] = System.getenv('SNAPSHOT') -} - -if (snapshot) { - ext["rootVersionName"] = Configuration.snapshotVersionName -} else { - ext["rootVersionName"] = Configuration.versionName -} - -// Set up Sonatype repository -nexusPublishing { - repositories { - sonatype { - nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/")) - snapshotRepositoryUrl.set(uri("https://s01.oss.sonatype.org/content/repositories/snapshots/")) - - stagingProfileId = sonatypeStagingProfileId - username = ossrhUsername - password = ossrhPassword - version = rootVersionName - } - } -} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 17a4486..2c5d623 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,5 @@ +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + pluginManagement { repositories { google() @@ -13,6 +15,9 @@ dependencyResolutionManagement { } } -rootProject.name = "tap-target-compose" -include(":sample-app") -include(":tap-target-compose") +rootProject.name = "tap-target-cmp" +include(":example") +include(":example:composeApp") +include(":example:androidApp") +include(":example:iosApp") +include(":taptarget") diff --git a/tap-target-compose/.gitignore b/tap-target-compose/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/tap-target-compose/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/tap-target-compose/build.gradle.kts b/tap-target-compose/build.gradle.kts deleted file mode 100644 index cd0adf7..0000000 --- a/tap-target-compose/build.gradle.kts +++ /dev/null @@ -1,50 +0,0 @@ -import com.psoffritti.taptargetcompose.Configuration -import org.gradle.kotlin.dsl.android - -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.kotlin.android) - alias(libs.plugins.compose.compiler) -} - -android { - namespace = "com.psoffritti.taptargetcompose" - compileSdk = Configuration.compileSdk - - defaultConfig { - minSdk = Configuration.minSdk - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } - buildFeatures { - compose = true - } -} - -dependencies { - val composeBom = platform(libs.androidx.compose.bom) - implementation(composeBom) - androidTestImplementation(composeBom) - implementation(libs.androidx.compose.animation) - implementation(libs.androidx.compose.foundation) - - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.ui.tooling) -} - -extra.apply { - set("PUBLISH_GROUP_ID", "com.pierfrancescosoffritti.taptargetcompose") - set("PUBLISH_ARTIFACT_ID", "core") - set("PUBLISH_VERSION", Configuration.versionName) - set("PUBLISH_DESCRIPTION", "A compose implementation of Material Design tap targets, for feature discovery.") -} - -apply(from = "../scripts/publish-module.gradle") \ No newline at end of file diff --git a/tap-target-compose/consumer-rules.pro b/tap-target-compose/consumer-rules.pro deleted file mode 100644 index e69de29..0000000 diff --git a/tap-target-compose/proguard-rules.pro b/tap-target-compose/proguard-rules.pro deleted file mode 100644 index 481bb43..0000000 --- a/tap-target-compose/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/tap-target-compose/src/main/AndroidManifest.xml b/tap-target-compose/src/main/AndroidManifest.xml deleted file mode 100644 index a5918e6..0000000 --- a/tap-target-compose/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/tap-target-compose/src/main/java/com/psoffritti/taptargetcompose/Rendering.kt b/tap-target-compose/src/main/java/com/psoffritti/taptargetcompose/Rendering.kt deleted file mode 100644 index f099e27..0000000 --- a/tap-target-compose/src/main/java/com/psoffritti/taptargetcompose/Rendering.kt +++ /dev/null @@ -1,561 +0,0 @@ -package com.psoffritti.taptargetcompose - -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper -import android.util.Log -import android.view.ViewGroup -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector1D -import androidx.compose.animation.core.FastOutLinearInEasing -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.TextMeasurer -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.drawText -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch -import kotlin.math.max -import kotlin.math.min -import kotlin.math.pow - -private const val DEBUG = false - -/** Padding between the tap target and the highlight circle. */ -private val TARGET_PADDING = 20.dp - -/** The horizontal margin for the text block */ -private val TEXT_HORIZONTAL_MARGIN = 40.dp -/** The vertical margin for the text block */ -private val TEXT_VERTICAL_MARGIN = 40.dp - -/** The margin between the outer circle and the text. */ -private val OUTER_CIRCLE_INTERNAL_MARGIN = 40.dp - -/** The maximum width of the text. */ -private val MAX_TEXT_WIDTH = 360f.dp - -/** The margin between the title and description text. */ -private val TEXT_SPACING = 2.dp - -private fun Dp.toPx(density: Density) = with(density) { toPx() } - -private const val LOG_TAG = "TapTarget" - -/** Composable responsible for drawing the tap target. */ -@Composable -internal fun TapTarget(tapTarget: TapTarget, onComplete: () -> Unit) { - val density = LocalDensity.current - val screenSizePx = Size( - LocalConfiguration.current.screenWidthDp * density.density, - LocalConfiguration.current.screenHeightDp * density.density - ) - - var lastTargetCenter by remember { mutableStateOf(Offset.Zero) } - // For moving targets, the coordinates can change, use a function - // to always get the latest. - val getTargetCenterPx = { - if (tapTarget.coordinates.isAttached) { - val center = tapTarget.coordinates.boundsInWindow().center - lastTargetCenter = center - center - } - else { - lastTargetCenter - } - } - - val targetMaxDimensionPx = remember(tapTarget) { - max(tapTarget.coordinates.size.width, tapTarget.coordinates.size.height) - } - val targetRadiusPx = targetMaxDimensionPx / 2 + TARGET_PADDING.toPx(density) - - // The radius of the outer circle of the tap target. - var outerCircleRadiusPx by remember { mutableFloatStateOf(0f) } - // The radius of the highlight circle shown around the target. - var highlightCircleRadiusPx by remember { mutableFloatStateOf(0f) } - // Whether we are animating in or out. - var animateIn by remember { mutableStateOf(true) } - // Whether the user clicked the target. - var targetClicked by remember { mutableStateOf(false) } - // Whether the user clicked outside the target. - var targetCancelled by remember { mutableStateOf(false) } - - val outerCircleScaleAnimatable = remember { Animatable(0f) } - val highlightCircleScaleAnimatable = remember { Animatable(0f) } - val tapTargetCircleScaleAnimatable = remember { Animatable(0f) } - val textAlphaScaleAnimatable = remember { Animatable(0f) } - - if (targetClicked) { - // The user tapped the target, notify the target. - tapTarget.onTargetClick() - targetClicked = false - } - - if ( - // We have completed the current target, and are now animating out. - !animateIn && - // The outer circle has finished collapsing. - outerCircleScaleAnimatable.value == 0f && - // The highlight circle has finished collapsing. - highlightCircleScaleAnimatable.value == 0f - ) { - // Animate out is complete. We are now switching to the next target and should animate in. - animateIn = true - - if (targetCancelled) { - // The user tapped outside the target, notify the target. - tapTarget.onTargetCancel() - targetCancelled = false - } - - // The animation is complete, notify that we are ready for the next target. - onComplete() - } - - if (animateIn) { - AnimateIn( - key1 = tapTarget, - outerCircleAnimatable = outerCircleScaleAnimatable, - highlightCircleAnimatable = highlightCircleScaleAnimatable, - tapTargetCircleAnimatable = tapTargetCircleScaleAnimatable, - textAlphaAnimatable = textAlphaScaleAnimatable - ) - } - else { - AnimateOut( - key1 = tapTarget, - outerCircleAnimatable = outerCircleScaleAnimatable, - highlightCircleAnimatable = highlightCircleScaleAnimatable, - textAlphaAnimatable= textAlphaScaleAnimatable - ) - } - - val maxTextWidthPx = MAX_TEXT_WIDTH.toPx(density) - val textHorizontalMarginPx = TEXT_HORIZONTAL_MARGIN.toPx(density) - val textVerticalMarginPx = TEXT_VERTICAL_MARGIN.toPx(density) - - val textWidthPx = min(screenSizePx.width, maxTextWidthPx) - textHorizontalMarginPx * 2 - - val constraints = Constraints.fixedWidth(textWidthPx.toInt()) - val textMeasurer = rememberTextMeasurer() - val titleMeasure = tapTarget.title.rememberMeasure(textMeasurer, constraints) - val descriptionMeasure = tapTarget.description.rememberMeasure(textMeasurer, constraints) - - val textBlockHeightPx = titleMeasure.size.height + - descriptionMeasure.size.height + - TEXT_SPACING.toPx(density) - - val textBlockTopLeft = getTextBlockOffset( - Size(textWidthPx, textBlockHeightPx), - screenSizePx, - getTargetCenterPx(), - targetRadiusPx, - textHorizontalMarginPx, - textVerticalMarginPx - ) - val textBlockRect = Rect( - textBlockTopLeft.x, - textBlockTopLeft.y, - textBlockTopLeft.x + textWidthPx, - textBlockTopLeft.y + textBlockHeightPx - ) - - val topLeftRadius = textBlockRect.topLeft.distanceTo(getTargetCenterPx()) - val topRightRadius = textBlockRect.topRight.distanceTo(getTargetCenterPx()) - val bottomLeftRadius = textBlockRect.bottomLeft.distanceTo(getTargetCenterPx()) - val bottomRightRadius = textBlockRect.bottomRight.distanceTo(getTargetCenterPx()) - val maxRadius = max( - topLeftRadius, - topRightRadius, - bottomLeftRadius, - bottomRightRadius - ) - - outerCircleRadiusPx = maxRadius + OUTER_CIRCLE_INTERNAL_MARGIN.toPx(density) - highlightCircleRadiusPx = min(targetRadiusPx*2, outerCircleRadiusPx) - - TapTargetRenderer( - tapTarget, - onTargetCancel = { - targetCancelled = true - animateIn = false - }, - onTargetClick = { - targetClicked = true - animateIn = false - }, - outerCircleScaleProvider = { outerCircleScaleAnimatable.value }, - highlightCircleScaleProvider = { highlightCircleScaleAnimatable.value }, - tapTargetCircleScaleProvider = { tapTargetCircleScaleAnimatable.value }, - textAlphaProvider = { textAlphaScaleAnimatable.value }, - getTargetCenter = getTargetCenterPx, - targetRadius = targetRadiusPx, - outerCircleRadius = outerCircleRadiusPx, - highlightCircleRadius = highlightCircleRadiusPx, - textBlockTopLeft = textBlockTopLeft, - titleMeasure = titleMeasure, - descriptionMeasure = descriptionMeasure, - textBlockRect = textBlockRect - ) -} - -/** Component that draws the tap target. */ -@Composable -private fun TapTargetRenderer( - tapTarget: TapTarget, - onTargetClick: () -> Unit, - onTargetCancel: () -> Unit, - getTargetCenter: () -> Offset, - outerCircleScaleProvider: () -> Float, - highlightCircleScaleProvider: () -> Float, - tapTargetCircleScaleProvider: () -> Float, - textAlphaProvider: () -> Float, - targetRadius: Float, - outerCircleRadius: Float, - highlightCircleRadius: Float, - textBlockTopLeft: Offset, - titleMeasure: TextLayoutResult, - descriptionMeasure: TextLayoutResult, - textBlockRect: Rect -) { - Canvas( - modifier = Modifier - .fillMaxSize() - .pointerInput(tapTarget) { - detectTapGestures { tapOffset -> - when { - tapOffset.isOutsideCircle(getTargetCenter(), outerCircleRadius) -> { - // The user clicked outside the target - onTargetCancel() - } - - tapOffset.isInsideCircle(getTargetCenter(), targetRadius) -> { - // The user clicked the target - onTargetClick() - } - } - } - } // Add transparency to the entire canvas, so we can show what's below the tap target area. - .graphicsLayer(alpha = 0.99f) - ) { - // Don't draw the circles if they are smaller than the targetRadius. - // Otherwise we would draw above the tap target during animation. - if (outerCircleRadius * outerCircleScaleProvider() < targetRadius) { - return@Canvas - } - - // Draw outer circle - drawCircle( - center = getTargetCenter(), - radius = outerCircleRadius * outerCircleScaleProvider(), - color = tapTarget.style.backgroundColor, - alpha = tapTarget.style.backgroundAlpha - ) - - // Draw highlight circle - drawCircle( - center = getTargetCenter(), - radius = highlightCircleRadius * highlightCircleScaleProvider(), - color = tapTarget.style.tapTargetHighlightColor, - alpha = 1 - highlightCircleScaleProvider().pow(4) - ) - - // XOR circle used to reveal the tap target, since we are drawing the other circles above it. - drawCircle( - center = getTargetCenter(), - radius = targetRadius + ((targetRadius / 10) * tapTargetCircleScaleProvider()), - color = tapTarget.style.tapTargetHighlightColor, - blendMode = BlendMode.Xor - ) - - drawText( - textLayoutResult = titleMeasure, - topLeft = textBlockTopLeft, - alpha = textAlphaProvider().pow(2) - ) - drawText( - textLayoutResult = descriptionMeasure, - topLeft = textBlockTopLeft.plus( - Offset(x = 0f, y = titleMeasure.size.height + TEXT_SPACING.toPx()) - ), - alpha = textAlphaProvider().pow(2) - ) - - if (DEBUG) { - // Draw the text block rect to see text bounds. - drawRect( - color = Color.Black.copy(alpha = 0.4f), - topLeft = textBlockRect.topLeft, - size = Size(textBlockRect.width, textBlockRect.height) - ) - } - } -} - -/** - * Calculates and returns the top left coordinates of the text block. - * @param textBlockSize The size of the text block. - * @param targetCenter The center of the target. - * @param targetRadius The radius of the target. - * @param horizontalMargin The horizontal margin between the text block and the screen edge. - * @param verticalMargin The vertical margin between the text block and the screen edge. - */ -// TODO(issue#3) the entire screen size is used to position the text block, -// therefore it might overlap the status bar. -private fun getTextBlockOffset( - textBlockSize: Size, - screenSize: Size, - targetCenter: Offset, - targetRadius: Float, - horizontalMargin: Float, - verticalMargin: Float -): Offset { - // The X coordinate of the text block, if positioned to the left of the target. - val xLeft = max(targetCenter.x - textBlockSize.width, horizontalMargin) - // The X coordinate of the text block, if positioned to the right of the target. - val xRightTemp = targetCenter.x - val xRight = if (xRightTemp + textBlockSize.width > screenSize.width - horizontalMargin) { - // The text block would end outside of the margin. Make sure it doesn't. - screenSize.width - horizontalMargin - textBlockSize.width - } - else { - xRightTemp - } - - val xOffset = if (xLeft > 0) { - xLeft - } - else { - xRight - } - - // The Y coordinate of the text block, if positioned above the target. - val yTop = targetCenter.y - targetRadius - textBlockSize.height - verticalMargin - // The Y coordinate of the text block, if positioned below the target. - val yBottom = targetCenter.y + targetRadius + verticalMargin - - val yOffset = if (yTop > 0) { - yTop - } else { - yBottom - } - - return Offset(xOffset, yOffset) -} - -@Composable -private fun AnimateIn( - key1: Any?, - outerCircleAnimatable: Animatable, - highlightCircleAnimatable: Animatable, - tapTargetCircleAnimatable: Animatable, - textAlphaAnimatable: Animatable -) { - LaunchedEffect(key1) { - // Outer circle - launch { - outerCircleAnimatable.animateTo( - targetValue = 1f, - animationSpec = tween( - durationMillis = 300, - easing = FastOutSlowInEasing, - ), - ) - } - - // Highlight circle - launch { - highlightCircleAnimatable.animateTo( - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Restart, - ) - ) - } - - // Tap target circle - launch { - tapTargetCircleAnimatable.animateTo( - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(500, easing = FastOutLinearInEasing), - repeatMode = RepeatMode.Reverse, - ) - ) - } - - // Text alpha - launch { - textAlphaAnimatable.animateTo( - targetValue = 1f, - animationSpec = tween( - delayMillis = 200, - durationMillis = 300, - easing = FastOutSlowInEasing, - ), - ) - } - } -} - -@Composable -private fun AnimateOut( - key1: Any?, - outerCircleAnimatable: Animatable, - highlightCircleAnimatable: Animatable, - textAlphaAnimatable: Animatable -) { - LaunchedEffect(key1) { - // Outer circle - launch { - outerCircleAnimatable.animateTo( - targetValue = 0f, - animationSpec = tween( - durationMillis = 300, - easing = FastOutSlowInEasing, - ), - ) - } - - // Highlight circle - launch { - highlightCircleAnimatable.animateTo( - targetValue = 0f, - animationSpec = tween( - durationMillis = 300, - easing = FastOutSlowInEasing, - ) - ) - } - - // Text alpha - launch { - textAlphaAnimatable.animateTo( - targetValue = 0f, - animationSpec = tween( - durationMillis = 300, - easing = FastOutSlowInEasing, - ), - ) - } - } -} - -/** - * A composable that is drawn as an overlay over the entire screen. - * @param key The key used to know when to remove the overlay. - * @param content The content of the overlay. - */ -@Composable -internal fun Overlay( - key: Any?, - content: @Composable () -> Unit -) { - val context = LocalContext.current - val activity = context.getActivity() - if (activity == null) { - Log.d(LOG_TAG, "Can't show overlay, activity is null") - return - } - - val decor = activity.window.decorView as? ViewGroup - if (decor == null) { - Log.d(LOG_TAG, "Can't show overlay, decor is null") - return - } - - val layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - - // We want the overlay to be always rendered above the entire content of the app. - // To do this we need to access the decor view of the activity and add our overlay to it. - // Otherwise the tap target rendering would be limited to the size of the [TapTargetCoordinator]. - DisposableEffect(key) { - val composeView = ComposeView(context).apply { - setContent { content() } - } - decor.addView(composeView, layoutParams) - - onDispose { - decor.removeView(composeView) - } - } -} - -private fun Context.getActivity(): Activity? = when (this) { - is Activity -> this - is ContextWrapper -> baseContext.getActivity() - else -> null -} - -@Composable -private fun TextDefinition.rememberMeasure( - textMeasurer: TextMeasurer, - constraints: Constraints -): TextLayoutResult { - return remember( - text, - constraints, - style.color, - style.fontSize, - style.fontWeight, - style.textAlign, - style.lineHeight, - style.fontFamily, - style.textDecoration, - style.fontStyle, - style.letterSpacing - ) { - textMeasurer.measure( - AnnotatedString(text), - constraints = constraints, - style = TextStyle( - color = style.color, - fontSize = style.fontSize, - fontWeight = style.fontWeight, - textAlign = style.textAlign, - lineHeight = style.lineHeight, - fontFamily = style.fontFamily, - textDecoration = style.textDecoration, - fontStyle = style.fontStyle, - letterSpacing = style.letterSpacing - ) - ) - } -} \ No newline at end of file diff --git a/taptarget/build.gradle.kts b/taptarget/build.gradle.kts new file mode 100644 index 0000000..12f9fc5 --- /dev/null +++ b/taptarget/build.gradle.kts @@ -0,0 +1,64 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.android.lint) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.vanniktech.mavenPublish) +} + +kotlin { + androidLibrary { + namespace = "zone.ien.taptargetcmp" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + androidResources { + enable = true + } + + withHostTestBuilder { + } + + withDeviceTestBuilder { + sourceSetTreeName = "test" + }.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain.dependencies { + implementation(libs.compose.material3) + implementation(libs.compose.preview) + implementation(libs.compose.resources) + } + + commonTest.dependencies { + implementation(libs.kotlin.test) + } + + androidMain.dependencies { + implementation(libs.activity.compose) + } + + getByName("androidDeviceTest").dependencies { + implementation(libs.runner) + implementation(libs.core) + implementation(libs.junit) + } + + iosMain.dependencies { + } + } +} + +dependencies { + androidRuntimeClasspath(libs.compose.ui.tooling) +} + +compose.resources { + publicResClass = true + generateResClass = auto +} diff --git a/tap-target-compose/src/main/java/com/psoffritti/taptargetcompose/Math.kt b/taptarget/src/commonMain/kotlin/zone/ien/taptargetcmp/Math.kt similarity index 94% rename from tap-target-compose/src/main/java/com/psoffritti/taptargetcompose/Math.kt rename to taptarget/src/commonMain/kotlin/zone/ien/taptargetcmp/Math.kt index 13b31f9..70bd9f7 100644 --- a/tap-target-compose/src/main/java/com/psoffritti/taptargetcompose/Math.kt +++ b/taptarget/src/commonMain/kotlin/zone/ien/taptargetcmp/Math.kt @@ -1,4 +1,4 @@ -package com.psoffritti.taptargetcompose +package zone.ien.taptargetcmp import androidx.compose.ui.geometry.Offset import kotlin.math.pow diff --git a/taptarget/src/commonMain/kotlin/zone/ien/taptargetcmp/Rendering.kt b/taptarget/src/commonMain/kotlin/zone/ien/taptargetcmp/Rendering.kt new file mode 100644 index 0000000..8dc96cc --- /dev/null +++ b/taptarget/src/commonMain/kotlin/zone/ien/taptargetcmp/Rendering.kt @@ -0,0 +1,530 @@ +package zone.ien.taptargetcmp + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import kotlinx.coroutines.launch +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + +private const val DEBUG = false + +/** Padding between the tap target and the highlight circle. */ +private val TARGET_PADDING = 20.dp + +/** The horizontal margin for the text block */ +private val TEXT_HORIZONTAL_MARGIN = 40.dp + +/** The vertical margin for the text block */ +private val TEXT_VERTICAL_MARGIN = 40.dp + +/** The margin between the outer circle and the text. */ +private val OUTER_CIRCLE_INTERNAL_MARGIN = 40.dp + +/** The maximum width of the text. */ +private val MAX_TEXT_WIDTH = 360f.dp + +/** The margin between the title and description text. */ +private val TEXT_SPACING = 2.dp + +private fun Dp.toPx(density: Density) = with(density) { toPx() } + +private const val LOG_TAG = "TapTarget" + +/** Composable responsible for drawing the tap target. */ +@Composable +internal fun TapTarget(tapTarget: TapTarget, onComplete: () -> Unit) { + val density = LocalDensity.current + val containerSize = LocalWindowInfo.current.containerSize + val screenSizePx = Size( + containerSize.width.toFloat(), + containerSize.height.toFloat() + ) + + var lastTargetCenter by remember { mutableStateOf(Offset.Zero) } + // For moving targets, the coordinates can change, use a function + // to always get the latest. + val getTargetCenterPx = { + if (tapTarget.coordinates.isAttached) { + val center = tapTarget.coordinates.boundsInWindow().center + lastTargetCenter = center + center + } else { + lastTargetCenter + } + } + + val targetMaxDimensionPx = remember(tapTarget) { + max(tapTarget.coordinates.size.width, tapTarget.coordinates.size.height) + } + val targetRadiusPx = targetMaxDimensionPx / 2 + TARGET_PADDING.toPx(density) + + // The radius of the outer circle of the tap target. + var outerCircleRadiusPx by remember { mutableFloatStateOf(0f) } + // The radius of the highlight circle shown around the target. + var highlightCircleRadiusPx by remember { mutableFloatStateOf(0f) } + // Whether we are animating in or out. + var animateIn by remember { mutableStateOf(true) } + // Whether the user clicked the target. + var targetClicked by remember { mutableStateOf(false) } + // Whether the user clicked outside the target. + var targetCancelled by remember { mutableStateOf(false) } + + val outerCircleScaleAnimatable = remember { Animatable(0f) } + val highlightCircleScaleAnimatable = remember { Animatable(0f) } + val tapTargetCircleScaleAnimatable = remember { Animatable(0f) } + val textAlphaScaleAnimatable = remember { Animatable(0f) } + + if (targetClicked) { + // The user tapped the target, notify the target. + tapTarget.onTargetClick() + targetClicked = false + } + + if ( + // We have completed the current target, and are now animating out. + !animateIn && + // The outer circle has finished collapsing. + outerCircleScaleAnimatable.value == 0f && + // The highlight circle has finished collapsing. + highlightCircleScaleAnimatable.value == 0f + ) { + // Animate out is complete. We are now switching to the next target and should animate in. + animateIn = true + + if (targetCancelled) { + // The user tapped outside the target, notify the target. + tapTarget.onTargetCancel() + targetCancelled = false + } + + // The animation is complete, notify that we are ready for the next target. + onComplete() + } + + if (animateIn) { + AnimateIn( + key1 = tapTarget, + outerCircleAnimatable = outerCircleScaleAnimatable, + highlightCircleAnimatable = highlightCircleScaleAnimatable, + tapTargetCircleAnimatable = tapTargetCircleScaleAnimatable, + textAlphaAnimatable = textAlphaScaleAnimatable + ) + } else { + AnimateOut( + key1 = tapTarget, + outerCircleAnimatable = outerCircleScaleAnimatable, + highlightCircleAnimatable = highlightCircleScaleAnimatable, + textAlphaAnimatable = textAlphaScaleAnimatable + ) + } + + val maxTextWidthPx = MAX_TEXT_WIDTH.toPx(density) + val textHorizontalMarginPx = TEXT_HORIZONTAL_MARGIN.toPx(density) + val textVerticalMarginPx = TEXT_VERTICAL_MARGIN.toPx(density) + + val textWidthPx = min(screenSizePx.width, maxTextWidthPx) - textHorizontalMarginPx * 2 + + val constraints = Constraints.fixedWidth(textWidthPx.toInt()) + val textMeasurer = rememberTextMeasurer() + val titleMeasure = tapTarget.title.rememberMeasure(textMeasurer, constraints) + val descriptionMeasure = tapTarget.description.rememberMeasure(textMeasurer, constraints) + + val textBlockHeightPx = titleMeasure.size.height + + descriptionMeasure.size.height + + TEXT_SPACING.toPx(density) + + val textBlockTopLeft = getTextBlockOffset( + Size(textWidthPx, textBlockHeightPx), + screenSizePx, + getTargetCenterPx(), + targetRadiusPx, + textHorizontalMarginPx, + textVerticalMarginPx + ) + val textBlockRect = Rect( + textBlockTopLeft.x, + textBlockTopLeft.y, + textBlockTopLeft.x + textWidthPx, + textBlockTopLeft.y + textBlockHeightPx + ) + + val topLeftRadius = textBlockRect.topLeft.distanceTo(getTargetCenterPx()) + val topRightRadius = textBlockRect.topRight.distanceTo(getTargetCenterPx()) + val bottomLeftRadius = textBlockRect.bottomLeft.distanceTo(getTargetCenterPx()) + val bottomRightRadius = textBlockRect.bottomRight.distanceTo(getTargetCenterPx()) + val maxRadius = max( + topLeftRadius, + topRightRadius, + bottomLeftRadius, + bottomRightRadius + ) + + outerCircleRadiusPx = maxRadius + OUTER_CIRCLE_INTERNAL_MARGIN.toPx(density) + highlightCircleRadiusPx = min(targetRadiusPx * 2, outerCircleRadiusPx) + + TapTargetRenderer( + tapTarget, + onTargetCancel = { + targetCancelled = true + animateIn = false + }, + onTargetClick = { + targetClicked = true + animateIn = false + }, + outerCircleScaleProvider = { outerCircleScaleAnimatable.value }, + highlightCircleScaleProvider = { highlightCircleScaleAnimatable.value }, + tapTargetCircleScaleProvider = { tapTargetCircleScaleAnimatable.value }, + textAlphaProvider = { textAlphaScaleAnimatable.value }, + getTargetCenter = getTargetCenterPx, + targetRadius = targetRadiusPx, + outerCircleRadius = outerCircleRadiusPx, + highlightCircleRadius = highlightCircleRadiusPx, + textBlockTopLeft = textBlockTopLeft, + titleMeasure = titleMeasure, + descriptionMeasure = descriptionMeasure, + textBlockRect = textBlockRect + ) +} + +/** Component that draws the tap target. */ +@Composable +private fun TapTargetRenderer( + tapTarget: TapTarget, + onTargetClick: () -> Unit, + onTargetCancel: () -> Unit, + getTargetCenter: () -> Offset, + outerCircleScaleProvider: () -> Float, + highlightCircleScaleProvider: () -> Float, + tapTargetCircleScaleProvider: () -> Float, + textAlphaProvider: () -> Float, + targetRadius: Float, + outerCircleRadius: Float, + highlightCircleRadius: Float, + textBlockTopLeft: Offset, + titleMeasure: TextLayoutResult, + descriptionMeasure: TextLayoutResult, + textBlockRect: Rect +) { + Canvas( + modifier = Modifier + .fillMaxSize() + .pointerInput(tapTarget) { + detectTapGestures { tapOffset -> + when { + tapOffset.isOutsideCircle(getTargetCenter(), outerCircleRadius) -> { + // The user clicked outside the target + onTargetCancel() + } + + tapOffset.isInsideCircle(getTargetCenter(), targetRadius) -> { + // The user clicked the target + onTargetClick() + } + } + } + } // Add transparency to the entire canvas, so we can show what's below the tap target area. + .graphicsLayer(alpha = 0.99f) + ) { + // Don't draw the circles if they are smaller than the targetRadius. + // Otherwise we would draw above the tap target during animation. + if (outerCircleRadius * outerCircleScaleProvider() < targetRadius) { + return@Canvas + } + + // Draw outer circle + drawCircle( + center = getTargetCenter(), + radius = outerCircleRadius * outerCircleScaleProvider(), + color = tapTarget.style.backgroundColor, + alpha = tapTarget.style.backgroundAlpha + ) + + // Draw highlight circle + drawCircle( + center = getTargetCenter(), + radius = highlightCircleRadius * highlightCircleScaleProvider(), + color = tapTarget.style.tapTargetHighlightColor, + alpha = 1 - highlightCircleScaleProvider().pow(4) + ) + + // XOR circle used to reveal the tap target, since we are drawing the other circles above it. + drawCircle( + center = getTargetCenter(), + radius = targetRadius + ((targetRadius / 10) * tapTargetCircleScaleProvider()), + color = tapTarget.style.tapTargetHighlightColor, + blendMode = BlendMode.Xor + ) + + drawText( + textLayoutResult = titleMeasure, + topLeft = textBlockTopLeft, + alpha = textAlphaProvider().pow(2) + ) + drawText( + textLayoutResult = descriptionMeasure, + topLeft = textBlockTopLeft.plus( + Offset(x = 0f, y = titleMeasure.size.height + TEXT_SPACING.toPx()) + ), + alpha = textAlphaProvider().pow(2) + ) + + if (DEBUG) { + // Draw the text block rect to see text bounds. + drawRect( + color = Color.Black.copy(alpha = 0.4f), + topLeft = textBlockRect.topLeft, + size = Size(textBlockRect.width, textBlockRect.height) + ) + } + } +} + +/** + * Calculates and returns the top left coordinates of the text block. + * @param textBlockSize The size of the text block. + * @param targetCenter The center of the target. + * @param targetRadius The radius of the target. + * @param horizontalMargin The horizontal margin between the text block and the screen edge. + * @param verticalMargin The vertical margin between the text block and the screen edge. + */ +// TODO(issue#3) the entire screen size is used to position the text block, +// therefore it might overlap the status bar. +private fun getTextBlockOffset( + textBlockSize: Size, + screenSize: Size, + targetCenter: Offset, + targetRadius: Float, + horizontalMargin: Float, + verticalMargin: Float +): Offset { + // The X coordinate of the text block, if positioned to the left of the target. + val xLeft = max(targetCenter.x - textBlockSize.width, horizontalMargin) + // The X coordinate of the text block, if positioned to the right of the target. + val xRightTemp = targetCenter.x + val xRight = if (xRightTemp + textBlockSize.width > screenSize.width - horizontalMargin) { + // The text block would end outside of the margin. Make sure it doesn't. + screenSize.width - horizontalMargin - textBlockSize.width + } else { + xRightTemp + } + + val xOffset = if (xLeft > 0) { + xLeft + } else { + xRight + } + + // The Y coordinate of the text block, if positioned above the target. + val yTop = targetCenter.y - targetRadius - textBlockSize.height - verticalMargin + // The Y coordinate of the text block, if positioned below the target. + val yBottom = targetCenter.y + targetRadius + verticalMargin + + val yOffset = if (yTop > 0) { + yTop + } else { + yBottom + } + + return Offset(xOffset, yOffset) +} + +@Composable +private fun AnimateIn( + key1: Any?, + outerCircleAnimatable: Animatable, + highlightCircleAnimatable: Animatable, + tapTargetCircleAnimatable: Animatable, + textAlphaAnimatable: Animatable +) { + LaunchedEffect(key1) { + // Outer circle + launch { + outerCircleAnimatable.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing, + ), + ) + } + + // Highlight circle + launch { + highlightCircleAnimatable.animateTo( + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Restart, + ) + ) + } + + // Tap target circle + launch { + tapTargetCircleAnimatable.animateTo( + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(500, easing = FastOutLinearInEasing), + repeatMode = RepeatMode.Reverse, + ) + ) + } + + // Text alpha + launch { + textAlphaAnimatable.animateTo( + targetValue = 1f, + animationSpec = tween( + delayMillis = 200, + durationMillis = 300, + easing = FastOutSlowInEasing, + ), + ) + } + } +} + +@Composable +private fun AnimateOut( + key1: Any?, + outerCircleAnimatable: Animatable, + highlightCircleAnimatable: Animatable, + textAlphaAnimatable: Animatable +) { + LaunchedEffect(key1) { + // Outer circle + launch { + outerCircleAnimatable.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing, + ), + ) + } + + // Highlight circle + launch { + highlightCircleAnimatable.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing, + ) + ) + } + + // Text alpha + launch { + textAlphaAnimatable.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing, + ), + ) + } + } +} + +/** + * A composable that is drawn as an overlay over the entire screen. + * @param key The key used to know when to remove the overlay. + * @param content The content of the overlay. + */ + +@Composable +internal fun Overlay( + key: Any?, + content: @Composable () -> Unit +) { + // key가 변경되면 Popup이 재구성됨 + key(key) { + Popup( + properties = PopupProperties( + focusable = true, + dismissOnBackPress = false, + dismissOnClickOutside = false, + ) + ) { + content() + } + } +} + +@Composable +private fun TextDefinition.rememberMeasure( + textMeasurer: TextMeasurer, + constraints: Constraints +): TextLayoutResult { + return remember( + text, + constraints, + style.color, + style.fontSize, + style.fontWeight, + style.textAlign, + style.lineHeight, + style.fontFamily, + style.textDecoration, + style.fontStyle, + style.letterSpacing + ) { + textMeasurer.measure( + AnnotatedString(text), + constraints = constraints, + style = TextStyle( + color = style.color, + fontSize = style.fontSize, + fontWeight = style.fontWeight, + textAlign = style.textAlign, + lineHeight = style.lineHeight, + fontFamily = style.fontFamily, + textDecoration = style.textDecoration, + fontStyle = style.fontStyle, + letterSpacing = style.letterSpacing + ) + ) + } +} \ No newline at end of file diff --git a/tap-target-compose/src/main/java/com/psoffritti/taptargetcompose/TapTarget.kt b/taptarget/src/commonMain/kotlin/zone/ien/taptargetcmp/TapTarget.kt similarity index 66% rename from tap-target-compose/src/main/java/com/psoffritti/taptargetcompose/TapTarget.kt rename to taptarget/src/commonMain/kotlin/zone/ien/taptargetcmp/TapTarget.kt index cffedfd..ac22306 100644 --- a/tap-target-compose/src/main/java/com/psoffritti/taptargetcompose/TapTarget.kt +++ b/taptarget/src/commonMain/kotlin/zone/ien/taptargetcmp/TapTarget.kt @@ -1,7 +1,7 @@ -package com.psoffritti.taptargetcompose +package zone.ien.taptargetcmp -import androidx.annotation.FloatRange import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -21,21 +21,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.TextUnit -/** - * A composable that can shows tap targets in succession. - * @param showTapTargets Whether to show the tap targets or not. - * @param modifier The modifier to apply to this layout. - * @param onComplete Called when all tap targets have been shown. - * @param state The state of [TapTargetCoordinator], - * can be used to know which tap target is being shown. - * @param content The main content. Each composable in the [content] can be marked as a tap target. - */ @Composable fun TapTargetCoordinator( showTapTargets: Boolean, modifier: Modifier = Modifier, onComplete: () -> Unit = { }, - // TODO(issue#1) use rememberSavable state: TapTargetCoordinatorState = remember { TapTargetCoordinatorState() }, contentAlignment: Alignment = Alignment.Center, content: @Composable TapTargetScope.() -> Unit, @@ -46,20 +36,17 @@ fun TapTargetCoordinator( contentAlignment = contentAlignment, modifier = modifier ) { - // Always render the content. scope.content() + if (showTapTargets) { val currentTapTarget = state.currentTarget if (currentTapTarget != null) { - // Render the TapTarget in an overlay. - // The overlay is removed when the composable leaves the composition. - Overlay(key = Unit) { + Box(modifier = Modifier.fillMaxSize()) { TapTarget( tapTarget = currentTapTarget, onComplete = { state.currentTargetIndex++ if (state.currentTargetIndex >= state.tapTargets.size) { - // There are no TapTargets left to render. onComplete() } } @@ -70,22 +57,13 @@ fun TapTargetCoordinator( } } -/** The scope from which a tap target can be created, through [Modifier.tapTarget]. */ class TapTargetScope internal constructor(private val state: TapTargetCoordinatorState) { - /** - * Modifier used to mark a [Composable] as a tap target. - * @param precedence The precedence of the target. Targets are shown in order of precedence. - * @param tapTargetStyle The style of the tap target. - * @param onTargetClick Called when the target is clicked. - * @param onTargetCancel Called when the target is cancelled, by clicking outside the target. - */ fun Modifier.tapTarget( title: TextDefinition, description: TextDefinition, precedence: Int, tapTargetStyle: TapTargetStyle = TapTargetStyle.Default, - // TODO(issue#2) can we avoid intercepting the click instead of defining a callback? onTargetClick: () -> Unit = { }, onTargetCancel: () -> Unit = { }, ): Modifier { @@ -102,10 +80,6 @@ class TapTargetScope internal constructor(private val state: TapTargetCoordinato } } - /** - * The same as [Modifier.tapTarget], but takes a [TapTargetDefinition]. - * @see Modifier.tapTarget - */ fun Modifier.tapTarget(tapTargetDefinition: TapTargetDefinition): Modifier { return tapTarget( tapTargetDefinition.title, @@ -118,7 +92,6 @@ class TapTargetScope internal constructor(private val state: TapTargetCoordinato } } -/** The content of a tap target. */ data class TapTargetDefinition( val title: TextDefinition, val description: TextDefinition, @@ -128,7 +101,6 @@ data class TapTargetDefinition( val onTargetCancel: () -> Unit = { }, ) -/** Keeps track of which tap target is currently being shown. */ class TapTargetCoordinatorState internal constructor() { internal val tapTargets = mutableStateMapOf() internal var currentTargetIndex by mutableIntStateOf(0) @@ -136,16 +108,6 @@ class TapTargetCoordinatorState internal constructor() { val currentTarget get() = tapTargets[currentTargetIndex] } -/** - * Defines a tap target. - * @param precedence The precedence of the target. Tap Targets are shown in order of precedence. - * @param title The title of the tap target. - * @param description The description of the tap target. - * @param coordinates The [LayoutCoordinates] of the target. - * @param style The style of the tap target. - * @param onTargetClick Called when the target is clicked. - * @param onTargetCancel Called when the target is cancelled, by clicking outside the target. - */ class TapTarget internal constructor( val precedence: Int, val title: TextDefinition, @@ -156,7 +118,6 @@ class TapTarget internal constructor( val onTargetCancel: () -> Unit, ) -/** Defines a text block shown in the tap target. */ data class TextDefinition( val text: String, internal val textStyle: TextStyle = TextStyle.Default, @@ -185,15 +146,8 @@ data class TextDefinition( ) } -/** - * Defines the look and feel of a tap target. - * @param backgroundColor The background color of the main circle. - * @param backgroundAlpha The alpha of the main circle. - * @param tapTargetHighlightColor The color of the highlight circle. - */ data class TapTargetStyle( val backgroundColor: Color = Color.Blue, - @FloatRange(from = 0.0, to = 1.0) val backgroundAlpha: Float = 1f, val tapTargetHighlightColor: Color = Color.White, ) {