diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..56a49dc
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,41 @@
+name: Deploy Compose Web App
+
+on:
+ pull_request:
+ branches:
+ - main
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: write
+
+jobs:
+ test-and-build-and-deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - uses: actions/cache@v4
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/buildSrc/**/*.kt') }}
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
+ - name: Build
+ run: |
+ ./gradlew :composeApp:wasmJsBrowserDistribution --no-configuration-cache
+
+ - name: Deploy
+ uses: JamesIves/github-pages-deploy-action@v4
+ with:
+ branch: gh-pages
+ folder: composeApp/build/dist/wasmJs/productionExecutable
\ No newline at end of file
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 876b18e..7aa3c4e 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -54,4 +54,11 @@ jobs:
SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
- SONATYPE_STAGING_PROFILE_ID: ${{ secrets.SONATYPE_STAGING_PROFILE_ID }}
\ No newline at end of file
+ SONATYPE_STAGING_PROFILE_ID: ${{ secrets.SONATYPE_STAGING_PROFILE_ID }}
+
+
+ - name: HTTP Request Action
+ uses: fjogeleit/http-request-action@v1.16.4
+ with:
+ url: 'https://ossrh-staging-api.central.sonatype.com/manual/upload/defaultRepository/ly.com.tahaben'
+ bearerToken: ${{ secrets.OSSRH_HEADER }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index bbfdafb..0b94248 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,4 +24,15 @@ local.properties
/.idea/migrations.xml
/.idea/misc.xml
/.idea/vcs.xml
-/convention-plugins/build/*
\ No newline at end of file
+/convention-plugins/build/*
+**/build/
+xcuserdata
+!src/**/build/
+.idea
+captures
+*.xcodeproj/*
+!*.xcodeproj/project.pbxproj
+!*.xcodeproj/xcshareddata/
+!*.xcodeproj/project.xcworkspace/
+!*.xcworkspace/contents.xcworkspacedata
+**/xcshareddata/WorkspaceSettings.xcsettings
\ No newline at end of file
diff --git a/README.md b/README.md
index f2fac92..789b5a0 100644
--- a/README.md
+++ b/README.md
@@ -11,13 +11,25 @@
# Showcase Layout Compose
-Create a beautiful animated showcase effect for your compose UIs easily !
+Create beautiful animated showcase effects for your compose UIs easily!
-**Now with multiplatform support :D**
+**Now with multiplatform support and two different showcase layouts to choose from:**
+- **ShowcaseLayout**: Classic full-screen overlay with cutouts
+- **TargetShowcaseLayout**: Modern targeted highlighting with customizable shapes
+
+## Web demo
+[Click here](https://tahaak67.github.io/ShowcaseLayoutCompose/index.html) to try showcase layout for web in your browser!
## Demo
-
+| ShowcaseLayout |
+|:---------------------------------------------------------------------------:|
+|
|
+
+https://github.com/user-attachments/assets/faa5dc19-606a-4731-80b1-44cbf6d08fdc
+
+
+
.
@@ -31,7 +43,7 @@ Showcase Layout Compose can be used in **both** Jetpack Compose (native Android)
Add the dependency to your module's `build.gradle` file like below
``` kotlin
-implementation("ly.com.tahaben:showcase-layout-compose:1.0.5")
+implementation("ly.com.tahaben:showcase-layout-compose:1.0.8")
```
## Usage
@@ -73,7 +85,10 @@ Text(
modifier = Modifier.showcase(
// should start with 1 and increment with 1 for each time you use Modifier.showcase()
index = 1,
- message =
+ message = ShowcaseMsg(
+ "This is a showcase message",
+ textStyle = TextStyle(color = Color.White)
+ )
),
text = "ShowcaseLayout Test 1"
)
@@ -122,7 +137,96 @@ similarly you can show a greeting using showGreeting and passing th
-Done, our text is now showcased!, customize it further with Additional parameters
+Done, our text is now showcased!, customize it further with Additional parameters.
+
+## TargetShowcaseLayout (New!)
+
+Starting from version 1.0.6, Showcase Layout Compose now offers a new layout option: `TargetShowcaseLayout`. This layout provides a different visual approach to showcasing UI elements by highlighting specific targets with customizable shapes rather than the full-screen approach of the original ShowcaseLayout.
+
+### Key Features
+
+- Highlights target elements with customizable shapes (circle, rectangle, or rounded rectangle)
+- Smooth animations between targets
+- Pulsing effect around the target for better visibility
+- All the same customization options as the original ShowcaseLayout
+
+### Usage
+
+You can use TargetShowcaseLayout directly:
+
+```kotlin
+var isShowcasing by remember { mutableStateOf(true) }
+
+TargetShowcaseLayout(
+ isShowcasing = isShowcasing,
+ onFinish = { isShowcasing = false },
+ targetShape = TargetShape.ROUNDED_RECTANGLE, // CIRCLE, RECTANGLE, or ROUNDED_RECTANGLE
+ cornerRadius = 8.dp, // Only used with ROUNDED_RECTANGLE
+ animateToNextTarget = true, // Smooth animation between targets
+ greeting = ShowcaseMsg(
+ "Welcome to TargetShowcaseLayout!",
+ textStyle = TextStyle(color = Color.White)
+ )
+) {
+ // Your UI content here
+ Column {
+ Text(
+ modifier = Modifier.showcase(
+ index = 1,
+ message = ShowcaseMsg(
+ "This element is highlighted with TargetShowcaseLayout",
+ textStyle = TextStyle(color = Color.White)
+ )
+ ),
+ text = "Target Showcase Example"
+ )
+ }
+}
+```
+
+| TargetShowcaseLayout with CIRCLE shape | TargetShowcaseLayout with RECTANGLE shape | TargetShowcaseLayout with ROUNDED_RECTANGLE shape |
+|:------------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------:|:------------------------------------------------------------------------------------------------------:|
+|  |  |  |
+
+### TargetShowcaseLayout vs ShowcaseLayout
+
+| Feature | TargetShowcaseLayout | ShowcaseLayout |
+|---------|---------------------|----------------|
+| Visual style | Highlights specific targets with shapes | Full-screen overlay with cutouts |
+| Target shapes | Circle, Rectangle, Rounded Rectangle | Circle, Rectangle, Rounded Rectangle |
+| Animations | Smooth transitions between targets | Fade transitions |
+| Pulsing effect | Yes | No |
+| Use cases | Focused UI tours, precise element highlighting | General app tours, feature introductions |
+
+### TargetShowcaseLayout Parameters
+
+In addition to the parameters shared with ShowcaseLayout, TargetShowcaseLayout offers:
+
+```kotlin
+TargetShowcaseLayout(
+ // Common parameters (same as ShowcaseLayout)
+ isShowcasing = isShowcasing,
+ isDarkLayout = false,
+ initIndex = 0,
+ animationDuration = 1000,
+ onFinish = { isShowcasing = false },
+ greeting = ShowcaseMsg(
+ "Welcome to TargetShowcaseLayout!",
+ textStyle = TextStyle(color = Color.White)
+ ),
+ lineThickness = 5.dp,
+
+ // TargetShowcaseLayout specific parameters
+ targetShape = TargetShape.CIRCLE, // CIRCLE, RECTANGLE, or ROUNDED_RECTANGLE
+ cornerRadius = 8.dp, // Only used with ROUNDED_RECTANGLE
+ animateToNextTarget = true // Smooth animation between targets, otherwise shrink and expand on each target
+) {
+ // Your UI content here
+}
+```
+
+
+
#### Additional parameters
@@ -294,11 +398,17 @@ In recent releases logs have been disabled by default, to print log statement of
```
-## Complete Example
+## Complete Examples
+
+### ShowcaseLayout Example
+For a complete example of the original ShowcaseLayout, check out [MainScreen.kt](https://github.com/tahaak67/ShowcaseLayoutCompose/blob/main/app/src/main/java/ly/com/tahaben/showcaselayoutcompose/ui/MainScreen.kt).
+
+### TargetShowcaseLayout Example
+For an example of the new TargetShowcaseLayout, check out the [App.kt](https://github.com/tahaak67/ShowcaseLayoutCompose/blob/main/composeApp/src/commonMain/kotlin/App.kt) file in the composeApp module.
+
+You can also clone/download this repository and run the demo app to see both layouts in action.
+
-For a complete example check
-out [MainScreen.kt](https://github.com/tahaak67/ShowcaseLayoutCompose/blob/main/app/src/main/java/ly/com/tahaben/showcaselayoutcompose/ui/MainScreen.kt) \
-or clone/download this repository and check the app module.
## Contributing
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index 9344f8f..0000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,68 +0,0 @@
-plugins {
- id 'com.android.application'
- id 'org.jetbrains.kotlin.android'
-}
-
-android {
- namespace "ly.com.tahaben.showcaselayoutcompose"
- compileSdk 34
-
- defaultConfig {
- applicationId "ly.com.tahaben.showcaselayoutcompose"
- minSdk 21
- targetSdk 33
- versionCode 5
- versionName "1.0.4"
-
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- vectorDrawables {
- useSupportLibrary true
- }
- }
-
- buildTypes {
- release {
- minifyEnabled 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
- buildConfig true
- }
- composeOptions {
- kotlinCompilerExtensionVersion '1.5.11'
- }
- packagingOptions {
- resources {
- excludes += '/META-INF/{AL2.0,LGPL2.1}'
- }
- }
-}
-
-dependencies {
- def composeBom = platform('androidx.compose:compose-bom:2024.02.02')
- implementation composeBom
- androidTestImplementation composeBom
- implementation 'androidx.core:core-ktx:1.12.0'
- implementation "androidx.compose.ui:ui"
- implementation "androidx.compose.material:material"
- implementation("androidx.navigation:navigation-compose:2.7.6")
- implementation "androidx.compose.ui:ui-tooling-preview"
- implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
- implementation 'androidx.activity:activity-compose:1.8.2'
- implementation project(':showcase-layout-compose')
- testImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.test.ext:junit:1.1.5'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
- androidTestImplementation "androidx.compose.ui:ui-test-junit4"
- debugImplementation "androidx.compose.ui:ui-tooling"
- debugImplementation "androidx.compose.ui:ui-test-manifest"
-}
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..98049b2
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,67 @@
+plugins {
+ alias(libs.plugins.androidApplication)
+ alias(libs.plugins.jetbrainsKotlinAndroid)
+ alias(libs.plugins.jetbrainsCompose)
+ alias(libs.plugins.compose.compiler)
+}
+
+android {
+ namespace = "ly.com.tahaben.showcaselayoutcompose"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "ly.com.tahaben.showcaselayoutcompose"
+ minSdk = 21
+ targetSdk = 33
+ versionCode= 5
+ versionName ="1.0.4"
+
+ testInstrumentationRunner= "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary= true
+ }
+ }
+
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility= JavaVersion.VERSION_1_8
+ targetCompatibility =JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ buildFeatures {
+ compose =true
+ buildConfig= true
+ }
+
+ packagingOptions {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2024.02.02")
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+ implementation ("androidx.core:core-ktx:1.12.0")
+ implementation ("androidx.compose.ui:ui")
+ implementation ("androidx.compose.material:material")
+ implementation("androidx.navigation:navigation-compose:2.7.6")
+ implementation ("androidx.compose.ui:ui-tooling-preview")
+ implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
+ implementation ("androidx.activity:activity-compose:1.8.2")
+ implementation (project(":showcase-layout-compose"))
+ testImplementation ("junit:junit:4.13.2")
+ androidTestImplementation ("androidx.test.ext:junit:1.1.5")
+ androidTestImplementation ("androidx.test.espresso:espresso-core:3.5.1")
+ androidTestImplementation ("androidx.compose.ui:ui-test-junit4")
+ debugImplementation ("androidx.compose.ui:ui-tooling")
+ debugImplementation ("androidx.compose.ui:ui-test-manifest")
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 481bb43..ff59496 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
+# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
diff --git a/build.gradle b/build.gradle
deleted file mode 100644
index 0cc73ff..0000000
--- a/build.gradle
+++ /dev/null
@@ -1,16 +0,0 @@
-buildscript {
- repositories {
- mavenCentral()
- google()
- }
-}// Top-level build file where you can add configuration options common to all sub-projects/modules.
-plugins {
- id 'com.android.library' version '8.2.2' apply false
- id 'org.jetbrains.kotlin.android' version '1.9.23' apply false
- id 'org.jetbrains.kotlin.multiplatform' version '1.9.23' apply false
- id 'org.jetbrains.compose' version '1.6.1' apply false
- id("io.github.gradle-nexus.publish-plugin") version "1.3.0"
-}
-
-
-apply from: "${rootDir}/scripts/publish-root.gradle"
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..4db520a
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,19 @@
+buildscript {
+ repositories {
+ mavenCentral()
+ google()
+ }
+}
+plugins {
+ // this is necessary to avoid the plugins to be loaded multiple times
+ // in each subproject's classloader
+ alias(libs.plugins.androidApplication) apply false
+ alias(libs.plugins.androidLibrary) apply false
+ alias(libs.plugins.jetbrainsCompose) apply false
+ alias(libs.plugins.compose.compiler) apply false
+ alias(libs.plugins.kotlinMultiplatform) apply false
+ alias(libs.plugins.jetbrainsKotlinAndroid) apply false
+ alias(libs.plugins.nexusPublishing)
+}
+
+apply("${rootDir}/scripts/publish-root.gradle")
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
new file mode 100644
index 0000000..ef4c291
--- /dev/null
+++ b/composeApp/build.gradle.kts
@@ -0,0 +1,122 @@
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
+import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
+import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.androidApplication)
+ alias(libs.plugins.jetbrainsCompose)
+ alias(libs.plugins.compose.compiler)
+}
+
+kotlin {
+ androidTarget {
+ compilations.all {
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ }
+ }
+
+ jvm("desktop")
+
+ @OptIn(ExperimentalWasmDsl::class)
+ wasmJs {
+ moduleName = "composeApp"
+ browser {
+ commonWebpackConfig {
+ outputFileName = "composeApp.js"
+ devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply {
+ static = (static ?: mutableListOf()).apply {
+ // Serve sources to debug inside browser
+ add(project.projectDir.path)
+ }
+ }
+ }
+ }
+ binaries.executable()
+ }
+ listOf(
+ iosX64(),
+ iosArm64(),
+ iosSimulatorArm64()
+ ).forEach { iosTarget ->
+ iosTarget.binaries.framework {
+ baseName = "ComposeApp"
+ isStatic = true
+ }
+ }
+
+ sourceSets {
+ val desktopMain by getting
+
+ androidMain.dependencies {
+ implementation(libs.compose.ui.tooling.preview)
+ implementation(libs.androidx.activity.compose)
+ }
+ commonMain.dependencies {
+ implementation(project(":showcase-layout-compose"))
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.material)
+ implementation(compose.material3)
+ implementation(compose.materialIconsExtended)
+ implementation(compose.ui)
+ implementation(compose.components.resources)
+ implementation(compose.components.uiToolingPreview)
+ }
+ desktopMain.dependencies {
+ implementation(compose.desktop.currentOs)
+ }
+ }
+}
+
+android {
+ namespace = "ly.com.tahaben.showcaselayoutcomposekmp"
+ compileSdk = libs.versions.android.compileSdk.get().toInt()
+
+ sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
+ sourceSets["main"].res.srcDirs("src/androidMain/res")
+ sourceSets["main"].resources.srcDirs("src/commonMain/resources")
+
+ defaultConfig {
+ applicationId = "ly.com.tahaben.showcaselayoutcomposekmp"
+ minSdk = libs.versions.android.minSdk.get().toInt()
+ targetSdk = libs.versions.android.targetSdk.get().toInt()
+ versionCode = 1
+ versionName = "1.0"
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ dependencies {
+ debugImplementation(libs.compose.ui.tooling)
+ }
+}
+
+compose.desktop {
+ application {
+ mainClass = "MainKt"
+
+ nativeDistributions {
+ targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
+ packageName = "ly.com.tahaben.showcaselayoutcomposekmp"
+ packageVersion = "1.0.0"
+ }
+ }
+}
+
+compose.experimental {
+ web.application {}
+}
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
new file mode 100644
index 0000000..b87d944
--- /dev/null
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/composeApp/src/androidMain/kotlin/OpenUrl.kt b/composeApp/src/androidMain/kotlin/OpenUrl.kt
new file mode 100644
index 0000000..f755b6f
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/OpenUrl.kt
@@ -0,0 +1,20 @@
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.core.content.ContextCompat
+
+
+class UrlLauncherAndroid(private val context: Context): UrlLauncher{
+
+ override fun openUrl(url: String): Boolean{
+ return try {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ ContextCompat.startActivity(context, intent, null)
+ true
+ }catch (e: Exception){
+
+ false
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/Platform.android.kt b/composeApp/src/androidMain/kotlin/Platform.android.kt
new file mode 100644
index 0000000..4f3ea05
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/Platform.android.kt
@@ -0,0 +1,7 @@
+import android.os.Build
+
+class AndroidPlatform : Platform {
+ override val name: String = "Android ${Build.VERSION.SDK_INT}"
+}
+
+actual fun getPlatform(): Platform = AndroidPlatform()
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/ly/com/tahaben/showcaselayoutcomposekmp/MainActivity.kt b/composeApp/src/androidMain/kotlin/ly/com/tahaben/showcaselayoutcomposekmp/MainActivity.kt
new file mode 100644
index 0000000..26f77c6
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/ly/com/tahaben/showcaselayoutcomposekmp/MainActivity.kt
@@ -0,0 +1,17 @@
+package ly.com.tahaben.showcaselayoutcomposekmp
+
+import App
+import UrlLauncherAndroid
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val urlLauncherAndroid = UrlLauncherAndroid(applicationContext)
+ setContent {
+ App(openUrl = urlLauncherAndroid::openUrl)
+ }
+ }
+}
diff --git a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..eca70cf
--- /dev/null
+++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..eca70cf
--- /dev/null
+++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..a571e60
Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..61da551
Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..c41dd28
Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..db5080a
Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..6dba46d
Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..da31a87
Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..15ac681
Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..b216f2d
Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..f25a419
Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..e96783c
Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml
new file mode 100644
index 0000000..b8c0401
--- /dev/null
+++ b/composeApp/src/androidMain/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ slc test
+
\ No newline at end of file
diff --git a/composeApp/src/commonMain/composeResources/drawable/circle.png b/composeApp/src/commonMain/composeResources/drawable/circle.png
new file mode 100644
index 0000000..6ff8d79
Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/circle.png differ
diff --git a/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml
new file mode 100644
index 0000000..d7bf795
--- /dev/null
+++ b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
diff --git a/composeApp/src/commonMain/composeResources/drawable/rounded_square.png b/composeApp/src/commonMain/composeResources/drawable/rounded_square.png
new file mode 100644
index 0000000..790baeb
Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/rounded_square.png differ
diff --git a/composeApp/src/commonMain/composeResources/drawable/square.png b/composeApp/src/commonMain/composeResources/drawable/square.png
new file mode 100644
index 0000000..5c3917b
Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/square.png differ
diff --git a/composeApp/src/commonMain/composeResources/drawable/triangle.png b/composeApp/src/commonMain/composeResources/drawable/triangle.png
new file mode 100644
index 0000000..c21bd88
Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/triangle.png differ
diff --git a/composeApp/src/commonMain/composeResources/font/noto_color_emoji_regular.ttf b/composeApp/src/commonMain/composeResources/font/noto_color_emoji_regular.ttf
new file mode 100644
index 0000000..041d9d9
Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/noto_color_emoji_regular.ttf differ
diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt
new file mode 100644
index 0000000..624a7a3
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/App.kt
@@ -0,0 +1,744 @@
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.*
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import ly.com.tahaben.showcase_layout_compose.domain.Level
+import ly.com.tahaben.showcase_layout_compose.domain.ShowcaseEventListener
+import ly.com.tahaben.showcase_layout_compose.model.*
+import org.jetbrains.compose.resources.ExperimentalResourceApi
+import org.jetbrains.compose.resources.painterResource
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import showcase_layout_compose_kmp.composeapp.generated.resources.*
+import kotlin.math.roundToInt
+
+@OptIn(ExperimentalResourceApi::class, ExperimentalMaterial3Api::class)
+@Preview
+@Composable
+fun App(openUrl: (String) -> Boolean, onWebLoadFinish: () -> Unit = {}) {
+ LaunchedEffect(key1 = Unit, block = { onWebLoadFinish() })
+
+ var selectedTarget by remember { mutableStateOf("Toolbar title") }
+ var selectTargetMenuExpaned by remember { mutableStateOf(false) }
+ var selectHeadMenuExpaned by remember { mutableStateOf(false) }
+ var selectTargetShapeMenuExpaned by remember { mutableStateOf(false) }
+ val clipboardManager = LocalClipboardManager.current
+ val snackbarHostState = remember { SnackbarHostState() }
+ var finishedSubsequentShowcase by remember { mutableStateOf(false) }
+ // State to track which layout is currently selected (ShowcaseLayout or TargetShowcaseLayout)
+ var useTargetShowcaseLayout by remember { mutableStateOf(false) }
+ val targets =
+ mapOf(
+ "Toolbar title" to 1,
+ "Toolbar menu icon" to 2,
+ "Showcase Button" to 3,
+ "Target Dropdown menu" to 4
+ )
+ val arrowHeadMap =
+ mapOf(
+ "Triangle" to Res.drawable.triangle,
+ "Circle" to Res.drawable.circle,
+ "Square" to Res.drawable.square,
+ "Rounded Square" to Res.drawable.rounded_square
+ )
+ val targetShapeMap =
+ mapOf(
+ "Triangle" to Res.drawable.triangle,
+ "Circle" to Res.drawable.circle,
+ "Rectangle" to Res.drawable.square,
+ "Rounded Rectangle" to Res.drawable.rounded_square
+ )
+ var selectedHead by remember { mutableStateOf("Triangle") }
+ val headType by derivedStateOf {
+ when (selectedHead) {
+ "Triangle" -> Head.TRIANGLE
+ "Circle" -> Head.CIRCLE
+ "Square" -> Head.SQUARE
+ "Rounded Square" -> Head.ROUND_SQUARE
+ else -> null
+ }
+ }
+ val coroutineScope = rememberCoroutineScope()
+ var isShowcasing by remember { mutableStateOf(false) }
+ var animateHead by remember { mutableStateOf(false) }
+ var animateMsg by remember { mutableStateOf(false) }
+ val msgBackground by remember { mutableStateOf(Color.Yellow) }
+ var msgCornerRadius by remember { mutableStateOf(0) }
+ var headSize by remember { mutableStateOf(25f) }
+ var lineThinckness by remember { mutableStateOf(5) }
+ var durationSliderValue by remember { mutableStateOf(500) }
+ var animationDuration by remember { mutableStateOf(500) }
+ var animateToNextTarget by remember { mutableStateOf(true) }
+ var targetShape by remember { mutableStateOf(TargetShape.CIRCLE) }
+ val targetShapeName by derivedStateOf {
+ when(targetShape){
+ TargetShape.CIRCLE -> "Circle"
+ TargetShape.RECTANGLE -> "Rectangle"
+ TargetShape.ROUNDED_RECTANGLE -> "Rounded Rectangle"
+ }
+ }
+ val scrollState = rememberScrollState()
+ val greetingMsg = buildAnnotatedString {
+ withStyle(ParagraphStyle(textAlign = TextAlign.Center)){
+ append("Welcome to ")
+ pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
+ append("Showcase Layout Compose Demo ")
+ pop()
+ append("lets take you on a quick tour!")
+ }
+ }
+
+ MyTheme(useDarkTheme = false) {
+ ShowcaseLayoutWrapper(
+ useTargetShowcaseLayout = useTargetShowcaseLayout,
+ isShowcasing = isShowcasing,
+ onFinish = { isShowcasing = false; finishedSubsequentShowcase = true },
+ greeting = ShowcaseMsg(greetingMsg, textStyle = TextStyle(color = Color.White)),
+ lineThickness = lineThinckness.dp,
+ animationDuration = animationDuration,
+ targetShape = targetShape,
+ animateToNextTarget = animateToNextTarget
+ ) {
+ Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
+ TopAppBar(title = {
+ Text(
+ modifier = Modifier
+ .showcase(
+ 1, message = ShowcaseMsg(
+ text = "This is the title of the toolbar 🤫",
+ msgBackground = Color(0xffb7ffb3),
+ roundedCorner = msgCornerRadius.dp,
+ arrow = Arrow(
+ animSize = animateHead,
+ head = headType,
+ headSize = headSize,
+ animationDuration = animationDuration
+ ),
+ enterAnim = if (animateMsg) MsgAnimation.FadeInOut(animationDuration) else MsgAnimation.None
+ )
+ ),
+ text = "Showcase Layout Compose Demo"
+ )
+ }, actions = {
+ IconButton(
+ onClick = {}, modifier = Modifier.showcase(
+ 2, message = ShowcaseMsg(
+ text = "This is the menu button click here to see the menu.",
+ msgBackground = Color(0xff0042fd),
+ roundedCorner = msgCornerRadius.dp,
+ arrow = Arrow(
+ animSize = animateHead,
+ head = headType,
+ headSize = headSize,
+ curved = true,
+ animationDuration = animationDuration
+ ),
+ textStyle = TextStyle(color = Color.White, fontWeight = FontWeight.Bold),
+ enterAnim = if (animateMsg) MsgAnimation.FadeInOut(animationDuration) else MsgAnimation.None
+ )
+ )
+ ) {
+ Icon(Icons.Default.MoreVert, contentDescription = null)
+ }
+ })
+ Column(
+ modifier = Modifier.fillMaxSize().padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.showcase(
+ 5, message = ShowcaseMsg(
+ text = "Switch between full screen showcase and target showcase from here, Try it now ;)",
+ msgBackground = Color(0xff000000),
+ textStyle = TextStyle(color = Color.White, fontWeight = FontWeight.Bold),
+ )
+ ),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("Layout Type", modifier = Modifier.width(100.dp))
+ Switch(
+ checked = useTargetShowcaseLayout,
+ onCheckedChange = { useTargetShowcaseLayout = it }
+ )
+ Text(if (useTargetShowcaseLayout) "Target Showcase Layout" else "Showcase Layout")
+ }
+
+ Row(
+ modifier = Modifier,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("Target", modifier = Modifier.width(100.dp))
+ ExposedDropdownMenuBox(
+ expanded = selectTargetMenuExpaned,
+ onExpandedChange = { selectTargetMenuExpaned = it }) {
+ OutlinedTextField(
+ modifier = Modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable).showcase(
+ 4, message = ShowcaseMsg(
+ text = buildAnnotatedString {
+ append("You can choose what target to showcase form here then click the ")
+ pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
+ append("Showcase!")
+ pop()
+ append(" button")
+ },
+ msgBackground = Color(0xff450099),
+ roundedCorner = msgCornerRadius.dp,
+ arrow = Arrow(
+ animSize = animateHead,
+ head = headType,
+ headSize = headSize,
+ animationDuration = animationDuration
+ ),
+ textStyle = TextStyle(color = Color.White, fontWeight = FontWeight.Bold),
+ enterAnim = if (animateMsg) MsgAnimation.FadeInOut(animationDuration) else MsgAnimation.None
+ )
+ ),
+ value = selectedTarget,
+ onValueChange = {},
+ readOnly = true,
+ singleLine = true,
+ label = {},
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = selectTargetMenuExpaned) },
+ colors = ExposedDropdownMenuDefaults.textFieldColors(),
+ )
+ ExposedDropdownMenu(
+ expanded = selectTargetMenuExpaned,
+ onDismissRequest = { selectTargetMenuExpaned = false }
+ ) {
+ targets.forEach { (name, index) ->
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = name,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ },
+ onClick = {
+ selectedTarget = name
+ selectTargetMenuExpaned = false
+ },
+ contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding
+ )
+ }
+ }
+ }
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("Head style", modifier = Modifier.width(100.dp))
+ ExposedDropdownMenuBox(
+ expanded = selectHeadMenuExpaned,
+ onExpandedChange = { selectHeadMenuExpaned = it }) {
+ OutlinedTextField(
+ modifier = Modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable),
+ value = selectedHead,
+ onValueChange = {},
+ readOnly = true,
+ singleLine = true,
+ label = { },
+ trailingIcon = {
+ Image(
+ modifier = Modifier.size(18.dp),
+ painter = painterResource(arrowHeadMap[selectedHead]!!),
+ contentDescription = null
+ )
+ },
+ colors = ExposedDropdownMenuDefaults.textFieldColors(),
+ )
+ ExposedDropdownMenu(
+ expanded = selectHeadMenuExpaned,
+ onDismissRequest = { selectHeadMenuExpaned = false }
+ ) {
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = "Triangle",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ },
+ onClick = {
+ selectedHead = "Triangle"
+ selectHeadMenuExpaned = false
+ },
+ contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
+ trailingIcon = {
+ Image(
+ modifier = Modifier.size(18.dp),
+ painter = painterResource(Res.drawable.triangle),
+ contentDescription = null
+ )
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = "Circle",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ },
+ onClick = {
+ selectedHead = "Circle"
+ selectHeadMenuExpaned = false
+ },
+ contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
+ trailingIcon = {
+ Image(
+ modifier = Modifier.size(18.dp),
+ painter = painterResource(Res.drawable.circle),
+ contentDescription = null
+ )
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = "Square",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ },
+ onClick = {
+ selectedHead = "Square"
+ selectHeadMenuExpaned = false
+ },
+ contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
+ trailingIcon = {
+ Image(
+ modifier = Modifier.size(18.dp),
+ painter = painterResource(Res.drawable.square),
+ contentDescription = null
+ )
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = "Rounded Square",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ },
+ onClick = {
+ selectedHead = "Rounded Square"
+ selectHeadMenuExpaned = false
+ },
+ contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
+ trailingIcon = {
+ Image(
+ modifier = Modifier.size(18.dp),
+ painter = painterResource(Res.drawable.rounded_square),
+ contentDescription = null
+ )
+ }
+ )
+ }
+ }
+ }
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("Target shape", modifier = Modifier.width(100.dp))
+ ExposedDropdownMenuBox(
+ expanded = selectTargetShapeMenuExpaned,
+ onExpandedChange = { selectTargetShapeMenuExpaned = it }) {
+ OutlinedTextField(
+ modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true),
+ value = targetShapeName,
+ onValueChange = {},
+ readOnly = true,
+ singleLine = true,
+ label = { },
+ trailingIcon = {
+ Image(
+ modifier = Modifier.size(18.dp),
+ painter = painterResource(targetShapeMap[targetShapeName]!!),
+ contentDescription = null
+ )
+ },
+ colors = ExposedDropdownMenuDefaults.textFieldColors(),
+ )
+ ExposedDropdownMenu(
+ expanded = selectTargetShapeMenuExpaned,
+ onDismissRequest = { selectTargetShapeMenuExpaned = false }
+ ) {
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = "Circle",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ },
+ onClick = {
+ targetShape = TargetShape.CIRCLE
+ selectTargetShapeMenuExpaned = false
+ },
+ contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
+ trailingIcon = {
+ Image(
+ modifier = Modifier.size(18.dp),
+ painter = painterResource(Res.drawable.circle),
+ contentDescription = null
+ )
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = "Rectangle",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ },
+ onClick = {
+ targetShape = TargetShape.RECTANGLE
+ selectTargetShapeMenuExpaned = false
+ },
+ contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
+ trailingIcon = {
+ Image(
+ modifier = Modifier.size(18.dp),
+ painter = painterResource(Res.drawable.square),
+ contentDescription = null
+ )
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = "Rounded Rectangle",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ },
+ onClick = {
+ targetShape = TargetShape.ROUNDED_RECTANGLE
+ selectTargetShapeMenuExpaned = false
+ },
+ contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
+ trailingIcon = {
+ Image(
+ modifier = Modifier.size(18.dp),
+ painter = painterResource(Res.drawable.rounded_square),
+ contentDescription = null
+ )
+ }
+ )
+ }
+ }
+ }
+ AnimatedVisibility(!isShowcasing, enter = fadeIn(), exit = fadeOut()) {
+ Column{
+ AnimatedVisibility(visible = !useTargetShowcaseLayout, enter = fadeIn(), exit = fadeOut()) {
+ Column {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Line thickness: ")
+ Slider(
+ value = lineThinckness.toFloat(),
+ onValueChange = { lineThinckness = it.toInt() },
+ valueRange = 1f..10f,
+ thumb = {
+ Label(
+ label = {
+ PlainTooltip(
+ modifier = Modifier
+ .requiredSize(45.dp, 25.dp)
+ .wrapContentWidth()
+ ) {
+ Text("${lineThinckness}dp")
+ }
+ }
+ ) {
+ Text(modifier = Modifier.drawBehind {
+ drawCircle(
+ color = Color.LightGray,
+ radius = 20.dp.toPx()
+ )
+ }, text = "${lineThinckness}dp")
+ }
+ }
+ )
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Message corner radius: ")
+ Slider(
+ value = msgCornerRadius.toFloat(),
+ onValueChange = { msgCornerRadius = it.toInt() },
+ valueRange = 0f..45f,
+ thumb = {
+ Label(
+ label = {
+ PlainTooltip(
+ modifier = Modifier
+ .requiredSize(45.dp, 25.dp)
+ .wrapContentWidth()
+ ) {
+ Text("${msgCornerRadius}dp")
+ }
+ }
+ ) {
+ Text(modifier = Modifier.drawBehind {
+ drawCircle(
+ color = Color.LightGray,
+ radius = 20.dp.toPx()
+ )
+ }, text = "${msgCornerRadius}dp")
+ }
+ }
+ )
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Head size: ")
+ Slider(
+ value = headSize,
+ onValueChange = { headSize = it },
+ valueRange = 10f..45f,
+ thumb = {
+ Label(
+ label = {
+ PlainTooltip(
+ modifier = Modifier
+ .requiredSize(45.dp, 25.dp)
+ .wrapContentWidth()
+ ) {
+ Text("${headSize}")
+ }
+ }
+ ) {
+ Text(modifier = Modifier.drawBehind {
+ drawCircle(
+ color = Color.LightGray,
+ radius = 20.dp.toPx()
+ )
+ }, text = headSize.roundToInt().toString())
+ }
+ }
+ )
+ }
+ }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Animation time: ")
+ Slider(
+ value = durationSliderValue.toFloat(),
+ onValueChange = { durationSliderValue = it.roundToInt() },
+ onValueChangeFinished = {animationDuration = durationSliderValue.div(3)},
+ steps = 24,
+ valueRange = 500f..3000f,
+ thumb = {
+ Label(
+ label = {
+ PlainTooltip(
+ modifier = Modifier
+ .requiredSize(45.dp, 25.dp)
+ .wrapContentWidth()
+ ) {
+ Text("${durationSliderValue}")
+ }
+ }
+ ) {
+ Text(modifier = Modifier.drawBehind {
+ drawRoundRect(
+ topLeft = Offset(-10f,0f),
+ color = Color.LightGray,
+ size = Size(70.dp.toPx(),25.dp.toPx()),
+ cornerRadius = CornerRadius(10f,10f)
+ )
+ }, text = "$durationSliderValue ms")
+ }
+ }
+ )
+ }
+ Row(
+ Modifier.selectable(
+ selected = animateMsg,
+ onClick = { animateMsg = !animateMsg },
+ role = Role.RadioButton
+ ), verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("Animate msg")
+ Checkbox(checked = animateMsg, onCheckedChange = { animateMsg = it })
+ }
+ Crossfade (useTargetShowcaseLayout) {
+ if (it){
+ Row(
+ modifier = Modifier.selectable(
+ selected = animateToNextTarget,
+ onClick = { animateToNextTarget = !animateToNextTarget },
+ role = Role.Checkbox
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("Animate to next target")
+ Checkbox(
+ checked = animateToNextTarget,
+ onCheckedChange = { animateToNextTarget = it }
+ )
+ }
+ }else{
+ Row(
+ Modifier.selectable(
+ selected = animateHead,
+ onClick = { if (!useTargetShowcaseLayout) animateHead = !animateHead },
+ role = Role.RadioButton,
+ enabled = !useTargetShowcaseLayout
+ ), verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ "Animate arrow head",
+ color = if (useTargetShowcaseLayout) Color.Gray else Color.Unspecified
+ )
+ Checkbox(
+ checked = animateHead,
+ onCheckedChange = { if (!useTargetShowcaseLayout) animateHead = it },
+ enabled = !useTargetShowcaseLayout
+ )
+ }
+ }
+ }
+ }
+ }
+ Row {
+ Button(
+ onClick = {
+ coroutineScope.launch {
+ showGreeting(
+ ShowcaseMsg(
+ "👋Hello this is a greeting \n\nGreeting is what usually is displayed before showcasing, it doesn't target any view but is used to display a message to the user 🧐\n\njust like this one.",
+ textStyle = TextStyle(color = Color.White, textAlign = TextAlign.Center)
+ )
+ )
+ }
+ }) {
+ Text("Show greeting!")
+ }
+ Spacer(Modifier.width(8.dp))
+ Button(
+ modifier = Modifier.showcase(
+ 3, message = ShowcaseMsg(
+ text = "Click here to showcase the target selected above.",
+ msgBackground = Color.Transparent,
+ textStyle = TextStyle(color = Color.White),
+ roundedCorner = msgCornerRadius.dp,
+ arrow = Arrow(
+ animSize = animateHead,
+ head = headType,
+ targetFrom = Side.Top,
+ headSize = headSize,
+ animationDuration = animationDuration
+ ),
+ enterAnim = if (animateMsg) MsgAnimation.FadeInOut(animationDuration) else MsgAnimation.None
+ )
+ ),
+ onClick = {
+ val indexToShowcase = targets[selectedTarget]!!
+ coroutineScope.launch {
+ showcaseItem(indexToShowcase)
+ }
+ }) {
+ Text("Showcase!")
+ }
+ }
+
+ Button(onClick = {
+ isShowcasing = true
+ }) {
+ Text("Subsequent Showcase!")
+ }
+ }
+ Box() {
+ SnackbarHost(
+ snackbarHostState,
+ modifier = Modifier.align(Alignment.BottomCenter)
+ )
+ Row(
+ Modifier.fillMaxWidth().padding(bottom = 16.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("By Tahaak67")
+ TextButton(onClick = {
+ if (!openUrl("https://github.com/tahaak67")) {
+ clipboardManager.setText(buildAnnotatedString { append("https://github.com/tahaak67") })
+ coroutineScope.launch { snackbarHostState.showSnackbar("Link copied") }
+ }
+ }) {
+ Text("Github")
+ }
+ TextButton(onClick = {
+ if (!openUrl("https://www.linkedin.com/in/tahabenly/")) {
+ clipboardManager.setText(buildAnnotatedString { append("https://www.linkedin.com/in/tahabenly/") })
+ coroutineScope.launch { snackbarHostState.showSnackbar("Link copied") }
+ }
+ }) {
+ Text("Linkedin")
+ }
+ }
+ }
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
+ TextButton(onClick = {
+ if (!openUrl("https://github.com/tahaak67/ShowcaseLayoutCompose")) {
+ clipboardManager.setText(buildAnnotatedString { append("https://github.com/tahaak67/ShowcaseLayoutCompose") })
+ coroutineScope.launch { snackbarHostState.showSnackbar("Link copied") }
+ }
+ }) {
+ Text("Showcase Layout Compose")
+ }
+ }
+ }
+ if (finishedSubsequentShowcase){
+ coroutineScope.launch {
+ delay(500)
+ showGreeting(ShowcaseMsg(buildAnnotatedString {
+ withStyle(ParagraphStyle(textAlign = TextAlign.Center)){
+ append("That's a lot of useful information!,\n ")
+ pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
+ append("i know ")
+ pop()
+ append(" i hope you where taking notes 📝 🫠")
+ }
+ }, TextStyle(color = Color.White)))
+ }
+ finishedSubsequentShowcase = false
+ }
+ registerEventListener(object: ShowcaseEventListener {
+ override fun onEvent(level: Level, event: String) {
+ println("$level: $event")
+ }
+ })
+ }
+ }
+}
+
+@Preview
+@Composable
+fun AppPreview() {
+ MyTheme {
+ App(openUrl = { false })
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/Color.kt b/composeApp/src/commonMain/kotlin/Color.kt
new file mode 100644
index 0000000..0fbb806
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/Color.kt
@@ -0,0 +1,68 @@
+package ly.com.tahaben.showcaselayoutcompose.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val md_theme_light_primary = Color(0xFF006971)
+val md_theme_light_onPrimary = Color(0xFFFFFFFF)
+val md_theme_light_primaryContainer = Color(0xFF83F3FF)
+val md_theme_light_onPrimaryContainer = Color(0xFF002022)
+val md_theme_light_secondary = Color(0xFF6D5E00)
+val md_theme_light_onSecondary = Color(0xFFFFFFFF)
+val md_theme_light_secondaryContainer = Color(0xFFFEE25C)
+val md_theme_light_onSecondaryContainer = Color(0xFF211B00)
+val md_theme_light_tertiary = Color(0xFF0F61A4)
+val md_theme_light_onTertiary = Color(0xFFFFFFFF)
+val md_theme_light_tertiaryContainer = Color(0xFFD2E4FF)
+val md_theme_light_onTertiaryContainer = Color(0xFF001C37)
+val md_theme_light_error = Color(0xFFBA1A1A)
+val md_theme_light_errorContainer = Color(0xFFFFDAD6)
+val md_theme_light_onError = Color(0xFFFFFFFF)
+val md_theme_light_onErrorContainer = Color(0xFF410002)
+val md_theme_light_background = Color(0xFFFAFDFD)
+val md_theme_light_onBackground = Color(0xFF191C1D)
+val md_theme_light_surface = Color(0xFFFAFDFD)
+val md_theme_light_onSurface = Color(0xFF191C1D)
+val md_theme_light_surfaceVariant = Color(0xFFDAE4E5)
+val md_theme_light_onSurfaceVariant = Color(0xFF3F484A)
+val md_theme_light_outline = Color(0xFF6F797A)
+val md_theme_light_inverseOnSurface = Color(0xFFEFF1F1)
+val md_theme_light_inverseSurface = Color(0xFF2D3131)
+val md_theme_light_inversePrimary = Color(0xFF4DD9E6)
+val md_theme_light_shadow = Color(0xFF000000)
+val md_theme_light_surfaceTint = Color(0xFF006971)
+val md_theme_light_outlineVariant = Color(0xFFBEC8C9)
+val md_theme_light_scrim = Color(0xFF000000)
+
+val md_theme_dark_primary = Color(0xFF4DD9E6)
+val md_theme_dark_onPrimary = Color(0xFF00363B)
+val md_theme_dark_primaryContainer = Color(0xFF004F55)
+val md_theme_dark_onPrimaryContainer = Color(0xFF83F3FF)
+val md_theme_dark_secondary = Color(0xFFE0C642)
+val md_theme_dark_onSecondary = Color(0xFF393000)
+val md_theme_dark_secondaryContainer = Color(0xFF534600)
+val md_theme_dark_onSecondaryContainer = Color(0xFFFEE25C)
+val md_theme_dark_tertiary = Color(0xFFA1C9FF)
+val md_theme_dark_onTertiary = Color(0xFF00325A)
+val md_theme_dark_tertiaryContainer = Color(0xFF004880)
+val md_theme_dark_onTertiaryContainer = Color(0xFFD2E4FF)
+val md_theme_dark_error = Color(0xFFFFB4AB)
+val md_theme_dark_errorContainer = Color(0xFF93000A)
+val md_theme_dark_onError = Color(0xFF690005)
+val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
+val md_theme_dark_background = Color(0xFF191C1D)
+val md_theme_dark_onBackground = Color(0xFFE0E3E3)
+val md_theme_dark_surface = Color(0xFF191C1D)
+val md_theme_dark_onSurface = Color(0xFFE0E3E3)
+val md_theme_dark_surfaceVariant = Color(0xFF3F484A)
+val md_theme_dark_onSurfaceVariant = Color(0xFFBEC8C9)
+val md_theme_dark_outline = Color(0xFF899294)
+val md_theme_dark_inverseOnSurface = Color(0xFF191C1D)
+val md_theme_dark_inverseSurface = Color(0xFFE0E3E3)
+val md_theme_dark_inversePrimary = Color(0xFF006971)
+val md_theme_dark_shadow = Color(0xFF000000)
+val md_theme_dark_surfaceTint = Color(0xFF4DD9E6)
+val md_theme_dark_outlineVariant = Color(0xFF3F484A)
+val md_theme_dark_scrim = Color(0xFF000000)
+
+
+val seed = Color(0xFF009AA5)
diff --git a/composeApp/src/commonMain/kotlin/Greeting.kt b/composeApp/src/commonMain/kotlin/Greeting.kt
new file mode 100644
index 0000000..887d835
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/Greeting.kt
@@ -0,0 +1,7 @@
+class Greeting {
+ private val platform = getPlatform()
+
+ fun greet(): String {
+ return "Hello, ${platform.name}!"
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/OpenUrl.kt b/composeApp/src/commonMain/kotlin/OpenUrl.kt
new file mode 100644
index 0000000..c053386
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/OpenUrl.kt
@@ -0,0 +1,4 @@
+
+interface UrlLauncher {
+ fun openUrl(url: String): Boolean
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/Platform.kt b/composeApp/src/commonMain/kotlin/Platform.kt
new file mode 100644
index 0000000..87ca3ff
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/Platform.kt
@@ -0,0 +1,5 @@
+interface Platform {
+ val name: String
+}
+
+expect fun getPlatform(): Platform
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/ShowcaseLayoutWrapper.kt b/composeApp/src/commonMain/kotlin/ShowcaseLayoutWrapper.kt
new file mode 100644
index 0000000..f583498
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/ShowcaseLayoutWrapper.kt
@@ -0,0 +1,68 @@
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import ly.com.tahaben.showcase_layout_compose.model.ShowcaseMsg
+import ly.com.tahaben.showcase_layout_compose.model.TargetShape
+import ly.com.tahaben.showcase_layout_compose.ui.ShowcaseLayout
+import ly.com.tahaben.showcase_layout_compose.ui.ShowcaseScope
+import ly.com.tahaben.showcase_layout_compose.ui.TargetShowcaseLayout
+
+/**
+ * A wrapper around ShowcaseLayout and TargetShowcaseLayout that allows switching between them.
+ *
+ * @param useTargetShowcaseLayout whether to use TargetShowcaseLayout or ShowcaseLayout
+ * @param isShowcasing to determine if showcase is starting or not.
+ * @param isDarkLayout if true the showcase view will be white instead of black.
+ * @param initIndex the initial value of counter, set this to 1 if you don't want a greeting screen before showcasing target.
+ * @param animationDuration total animation time taken when switching from current to next target in milliseconds.
+ * @param onFinish what happens when all items are showcased.
+ * @param greeting greeting message to be shown before showcasing the first composable, leave [initIndex] at 0 if you want to use this.
+ * @param lineThickness thickness of the arrow line in dp.
+ * @param targetShape the shape of the target highlight (RECTANGLE, CIRCLE, or ROUNDED_RECTANGLE).
+ * @param cornerRadius the corner radius for the ROUNDED_RECTANGLE shape in dp.
+ * @param animateToNextTarget if true, the target shape will animate smoothly from one target to the next when the index changes (only used in TargetShowcaseLayout).
+ */
+@Composable
+fun ShowcaseLayoutWrapper(
+ useTargetShowcaseLayout: Boolean,
+ isShowcasing: Boolean,
+ isDarkLayout: Boolean = false,
+ initIndex: Int = 0,
+ animationDuration: Int = 1000,
+ onFinish: () -> Unit,
+ greeting: ShowcaseMsg? = null,
+ lineThickness: Dp = 5.dp,
+ targetShape: TargetShape = TargetShape.RECTANGLE,
+ cornerRadius: Dp = 16.dp,
+ animateToNextTarget: Boolean = true,
+ content: @Composable ShowcaseScope.() -> Unit
+) {
+ if (useTargetShowcaseLayout) {
+ TargetShowcaseLayout(
+ isShowcasing = isShowcasing,
+ isDarkLayout = isDarkLayout,
+ initIndex = initIndex,
+ animationDuration = animationDuration,
+ onFinish = onFinish,
+ greeting = greeting,
+ lineThickness = lineThickness,
+ targetShape = targetShape,
+ cornerRadius = cornerRadius,
+ animateToNextTarget = animateToNextTarget,
+ content = content
+ )
+ } else {
+ ShowcaseLayout(
+ isShowcasing = isShowcasing,
+ isDarkLayout = isDarkLayout,
+ initIndex = initIndex,
+ animationDuration = animationDuration,
+ onFinish = onFinish,
+ greeting = greeting,
+ lineThickness = lineThickness,
+ targetShape = targetShape,
+ cornerRadius = cornerRadius,
+ content = content
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/Theme.kt b/composeApp/src/commonMain/kotlin/Theme.kt
new file mode 100644
index 0000000..30b1584
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/Theme.kt
@@ -0,0 +1,145 @@
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_background
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_error
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_errorContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_inverseOnSurface
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_inversePrimary
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_inverseSurface
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onBackground
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onError
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onErrorContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onPrimary
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onPrimaryContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onSecondary
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onSecondaryContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onSurface
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onSurfaceVariant
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onTertiary
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onTertiaryContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_outline
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_outlineVariant
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_primary
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_primaryContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_scrim
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_secondary
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_secondaryContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_surface
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_surfaceTint
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_surfaceVariant
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_tertiary
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_tertiaryContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_background
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_error
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_errorContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_inverseOnSurface
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_inversePrimary
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_inverseSurface
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onBackground
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onError
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onErrorContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onPrimary
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onPrimaryContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onSecondary
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onSecondaryContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onSurface
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onSurfaceVariant
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onTertiary
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onTertiaryContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_outline
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_outlineVariant
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_primary
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_primaryContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_scrim
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_secondary
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_secondaryContainer
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_surface
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_surfaceTint
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_surfaceVariant
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_tertiary
+import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_tertiaryContainer
+
+
+private val LightColors = lightColorScheme(
+ primary = md_theme_light_primary,
+ onPrimary = md_theme_light_onPrimary,
+ primaryContainer = md_theme_light_primaryContainer,
+ onPrimaryContainer = md_theme_light_onPrimaryContainer,
+ secondary = md_theme_light_secondary,
+ onSecondary = md_theme_light_onSecondary,
+ secondaryContainer = md_theme_light_secondaryContainer,
+ onSecondaryContainer = md_theme_light_onSecondaryContainer,
+ tertiary = md_theme_light_tertiary,
+ onTertiary = md_theme_light_onTertiary,
+ tertiaryContainer = md_theme_light_tertiaryContainer,
+ onTertiaryContainer = md_theme_light_onTertiaryContainer,
+ error = md_theme_light_error,
+ errorContainer = md_theme_light_errorContainer,
+ onError = md_theme_light_onError,
+ onErrorContainer = md_theme_light_onErrorContainer,
+ background = md_theme_light_background,
+ onBackground = md_theme_light_onBackground,
+ surface = md_theme_light_surface,
+ onSurface = md_theme_light_onSurface,
+ surfaceVariant = md_theme_light_surfaceVariant,
+ onSurfaceVariant = md_theme_light_onSurfaceVariant,
+ outline = md_theme_light_outline,
+ inverseOnSurface = md_theme_light_inverseOnSurface,
+ inverseSurface = md_theme_light_inverseSurface,
+ inversePrimary = md_theme_light_inversePrimary,
+ surfaceTint = md_theme_light_surfaceTint,
+ outlineVariant = md_theme_light_outlineVariant,
+ scrim = md_theme_light_scrim,
+ )
+
+
+private val DarkColors = darkColorScheme(
+ primary = md_theme_dark_primary,
+ onPrimary = md_theme_dark_onPrimary,
+ primaryContainer = md_theme_dark_primaryContainer,
+ onPrimaryContainer = md_theme_dark_onPrimaryContainer,
+ secondary = md_theme_dark_secondary,
+ onSecondary = md_theme_dark_onSecondary,
+ secondaryContainer = md_theme_dark_secondaryContainer,
+ onSecondaryContainer = md_theme_dark_onSecondaryContainer,
+ tertiary = md_theme_dark_tertiary,
+ onTertiary = md_theme_dark_onTertiary,
+ tertiaryContainer = md_theme_dark_tertiaryContainer,
+ onTertiaryContainer = md_theme_dark_onTertiaryContainer,
+ error = md_theme_dark_error,
+ errorContainer = md_theme_dark_errorContainer,
+ onError = md_theme_dark_onError,
+ onErrorContainer = md_theme_dark_onErrorContainer,
+ background = md_theme_dark_background,
+ onBackground = md_theme_dark_onBackground,
+ surface = md_theme_dark_surface,
+ onSurface = md_theme_dark_onSurface,
+ surfaceVariant = md_theme_dark_surfaceVariant,
+ onSurfaceVariant = md_theme_dark_onSurfaceVariant,
+ outline = md_theme_dark_outline,
+ inverseOnSurface = md_theme_dark_inverseOnSurface,
+ inverseSurface = md_theme_dark_inverseSurface,
+ inversePrimary = md_theme_dark_inversePrimary,
+ surfaceTint = md_theme_dark_surfaceTint,
+ outlineVariant = md_theme_dark_outlineVariant,
+ scrim = md_theme_dark_scrim,
+ )
+
+@Composable
+fun MyTheme(
+ useDarkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable() () -> Unit
+) {
+ val colors = if (!useDarkTheme) {
+ LightColors
+ } else {
+ DarkColors
+ }
+
+ androidx.compose.material3.MaterialTheme(
+ colorScheme = colors,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/composeApp/src/desktopMain/kotlin/Main.kt b/composeApp/src/desktopMain/kotlin/Main.kt
new file mode 100644
index 0000000..03a322a
--- /dev/null
+++ b/composeApp/src/desktopMain/kotlin/Main.kt
@@ -0,0 +1,8 @@
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+
+fun main() = application {
+ Window(onCloseRequest = ::exitApplication, title = "slc test") {
+ App(openUrl = UrlLuancherDesktop()::openUrl)
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/desktopMain/kotlin/OpenUrl.kt b/composeApp/src/desktopMain/kotlin/OpenUrl.kt
new file mode 100644
index 0000000..d169d51
--- /dev/null
+++ b/composeApp/src/desktopMain/kotlin/OpenUrl.kt
@@ -0,0 +1,15 @@
+import java.awt.Desktop
+import java.net.URI
+
+class UrlLuancherDesktop(): UrlLauncher{
+ override fun openUrl(url: String): Boolean {
+ return try {
+ Desktop.getDesktop().browse(URI(url))
+ true
+ } catch (e: UnsupportedOperationException) {
+
+ false
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/composeApp/src/desktopMain/kotlin/Platform.jvm.kt b/composeApp/src/desktopMain/kotlin/Platform.jvm.kt
new file mode 100644
index 0000000..5cacd96
--- /dev/null
+++ b/composeApp/src/desktopMain/kotlin/Platform.jvm.kt
@@ -0,0 +1,6 @@
+class JVMPlatform: Platform {
+ override val name: String = "Java ${System.getProperty("java.version")}"
+}
+
+actual fun getPlatform(): Platform = JVMPlatform()
+
diff --git a/composeApp/src/iosMain/kotlin/MainViewController.kt b/composeApp/src/iosMain/kotlin/MainViewController.kt
new file mode 100644
index 0000000..f9ab8e6
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/MainViewController.kt
@@ -0,0 +1,3 @@
+import androidx.compose.ui.window.ComposeUIViewController
+
+fun MainViewController() = ComposeUIViewController { App(openUrl = UrlLauncherIos()::openUrl) }
\ No newline at end of file
diff --git a/composeApp/src/iosMain/kotlin/OpenUrl.kt b/composeApp/src/iosMain/kotlin/OpenUrl.kt
new file mode 100644
index 0000000..bc61cb7
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/OpenUrl.kt
@@ -0,0 +1,17 @@
+import platform.Foundation.NSURL
+import platform.UIKit.UIApplication
+
+class UrlLauncherIos: UrlLauncher{
+ override fun openUrl(url: String): Boolean{
+ val nsurl = NSURL.URLWithString(url) ?: return false
+ return if (UIApplication.sharedApplication.canOpenURL(nsurl)) {
+ UIApplication.sharedApplication.openURL(nsurl)
+ true
+ } else {
+ // Handle case where the app can't open the URL (optional)
+ false
+ }
+ }
+}
+
+
\ No newline at end of file
diff --git a/composeApp/src/iosMain/kotlin/Platform.ios.kt b/composeApp/src/iosMain/kotlin/Platform.ios.kt
new file mode 100644
index 0000000..5cef987
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/Platform.ios.kt
@@ -0,0 +1,7 @@
+import platform.UIKit.UIDevice
+
+class IOSPlatform: Platform {
+ override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
+}
+
+actual fun getPlatform(): Platform = IOSPlatform()
\ No newline at end of file
diff --git a/composeApp/src/wasmJsMain/kotlin/Platform.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/Platform.wasmJs.kt
new file mode 100644
index 0000000..57b2e11
--- /dev/null
+++ b/composeApp/src/wasmJsMain/kotlin/Platform.wasmJs.kt
@@ -0,0 +1,5 @@
+class WasmPlatform: Platform {
+ override val name: String = "Web with Kotlin/Wasm"
+}
+
+actual fun getPlatform(): Platform = WasmPlatform()
\ No newline at end of file
diff --git a/composeApp/src/wasmJsMain/kotlin/Util.kt b/composeApp/src/wasmJsMain/kotlin/Util.kt
new file mode 100644
index 0000000..e826706
--- /dev/null
+++ b/composeApp/src/wasmJsMain/kotlin/Util.kt
@@ -0,0 +1,14 @@
+external fun openUrlWeb(url: String)
+class UrlLauncherWeb() : UrlLauncher {
+ override fun openUrl(url: String): Boolean {
+ return try {
+ openUrlWeb(url)
+ true
+ } catch (ex: Exception) {
+
+ false
+ }
+ }
+}
+
+external fun onLoadFinished()
\ No newline at end of file
diff --git a/composeApp/src/wasmJsMain/kotlin/main.kt b/composeApp/src/wasmJsMain/kotlin/main.kt
new file mode 100644
index 0000000..337e677
--- /dev/null
+++ b/composeApp/src/wasmJsMain/kotlin/main.kt
@@ -0,0 +1,21 @@
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.platform.LocalFontFamilyResolver
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.platform.Font
+import androidx.compose.ui.window.CanvasBasedWindow
+import showcase_layout_compose_kmp.composeapp.generated.resources.Res
+
+@OptIn(ExperimentalComposeUiApi::class)
+fun main() {
+ CanvasBasedWindow(canvasElementId = "ComposeTarget") {
+ val fontFamilyResolver = LocalFontFamilyResolver.current
+
+ LaunchedEffect(Unit) {
+ val notoEmojisBytes = Res.readBytes("/font/noto_color_emoji_regular.ttf")
+ val fontFamily = FontFamily(listOf(Font("NotoColorEmoji", notoEmojisBytes)))
+ fontFamilyResolver.preload(fontFamily)
+ }
+ App(openUrl = UrlLauncherWeb()::openUrl , onWebLoadFinish = ::onLoadFinished)
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/wasmJsMain/resources/index.html b/composeApp/src/wasmJsMain/resources/index.html
new file mode 100644
index 0000000..d161b07
--- /dev/null
+++ b/composeApp/src/wasmJsMain/resources/index.html
@@ -0,0 +1,54 @@
+
+
+
+
+ Showcase Layout Compose Demo
+
+
+
+
+
+
+
+
+
+
+
diff --git a/composeApp/src/wasmJsMain/resources/util.js b/composeApp/src/wasmJsMain/resources/util.js
new file mode 100644
index 0000000..3116a04
--- /dev/null
+++ b/composeApp/src/wasmJsMain/resources/util.js
@@ -0,0 +1,7 @@
+function openUrlWeb(url) {
+ window.open(url, '_blank'); // Open URL in a new tab
+}
+
+function onLoadFinished() {
+ document.dispatchEvent(new Event("app-loaded"));
+}
diff --git a/convention-plugins/src/main/kotlin/convention.publication.gradle.kts b/convention-plugins/src/main/kotlin/convention.publication.gradle.kts
index 59333c2..03ca2eb 100644
--- a/convention-plugins/src/main/kotlin/convention.publication.gradle.kts
+++ b/convention-plugins/src/main/kotlin/convention.publication.gradle.kts
@@ -1,4 +1,4 @@
-import java.util.Properties
+import java.util.*
plugins {
`maven-publish`
@@ -14,7 +14,7 @@ ext["ossrhUsername"] = null
ext["ossrhPassword"] = null
val publishGroupId: String = "ly.com.tahaben"
-val publishVersion: String = "1.0.5"
+val publishVersion: String = "1.0.8"
val publishArtifactId: String = "showcase-layout-compose"
@@ -52,7 +52,7 @@ publishing {
repositories {
maven {
name = "sonatype"
- setUrl("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/")
+ setUrl("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/")
credentials {
username = getExtraString("ossrhUsername")
password = getExtraString("ossrhPassword")
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..e3e33fc
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,40 @@
+[versions]
+agp = "8.2.2"
+android-compileSdk = "35"
+android-minSdk = "24"
+android-targetSdk = "35"
+androidx-activityCompose = "1.10.1"
+androidx-appcompat = "1.7.1"
+androidx-constraintlayout = "2.2.1"
+androidx-core-ktx = "1.16.0"
+androidx-espresso-core = "3.6.1"
+androidx-material = "1.12.0"
+androidx-test-junit = "1.2.1"
+compose = "1.8.2"
+compose-plugin = "1.8.1"
+junit = "4.13.2"
+kotlin = "2.1.21"
+nexusPublishing = "1.3.0"
+
+[libraries]
+kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
+kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
+androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
+androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" }
+androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
+compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
+compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }
+
+[plugins]
+androidApplication = { id = "com.android.application", version.ref = "agp" }
+androidLibrary = { id = "com.android.library", version.ref = "agp" }
+jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
+kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+nexusPublishing = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexusPublishing" }
\ No newline at end of file
diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig
new file mode 100644
index 0000000..479903c
--- /dev/null
+++ b/iosApp/Configuration/Config.xcconfig
@@ -0,0 +1,3 @@
+TEAM_ID=
+BUNDLE_ID=ly.com.tahaben.showcaselayoutcomposekmp
+APP_NAME=Showcase layout compose demo
\ No newline at end of file
diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..315d28a
--- /dev/null
+++ b/iosApp/iosApp.xcodeproj/project.pbxproj
@@ -0,0 +1,398 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; };
+ 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; };
+ 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; };
+ 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
+ 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; };
+ 7555FF7B242A565900829871 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; name = iosApp.app; path = "slc test.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ B92378962B6B1156000C7307 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 058557D7273AAEEB004C7B11 /* Preview Content */ = {
+ isa = PBXGroup;
+ children = (
+ 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */,
+ );
+ path = "Preview Content";
+ sourceTree = "";
+ };
+ 42799AB246E5F90AF97AA0EF /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ 7555FF72242A565900829871 = {
+ isa = PBXGroup;
+ children = (
+ AB1DB47929225F7C00F7AF9C /* Configuration */,
+ 7555FF7D242A565900829871 /* iosApp */,
+ 7555FF7C242A565900829871 /* Products */,
+ 42799AB246E5F90AF97AA0EF /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 7555FF7C242A565900829871 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 7555FF7B242A565900829871 /* iosApp.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 7555FF7D242A565900829871 /* iosApp */ = {
+ isa = PBXGroup;
+ children = (
+ 058557BA273AAA24004C7B11 /* Assets.xcassets */,
+ 7555FF82242A565900829871 /* ContentView.swift */,
+ 7555FF8C242A565B00829871 /* Info.plist */,
+ 2152FB032600AC8F00CF470E /* iOSApp.swift */,
+ 058557D7273AAEEB004C7B11 /* Preview Content */,
+ );
+ path = iosApp;
+ sourceTree = "";
+ };
+ AB1DB47929225F7C00F7AF9C /* Configuration */ = {
+ isa = PBXGroup;
+ children = (
+ AB3632DC29227652001CCB65 /* Config.xcconfig */,
+ );
+ path = Configuration;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 7555FF7A242A565900829871 /* iosApp */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */;
+ buildPhases = (
+ F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */,
+ 7555FF77242A565900829871 /* Sources */,
+ B92378962B6B1156000C7307 /* Frameworks */,
+ 7555FF79242A565900829871 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = iosApp;
+ packageProductDependencies = (
+ );
+ productName = iosApp;
+ productReference = 7555FF7B242A565900829871 /* iosApp.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 7555FF73242A565900829871 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ LastSwiftUpdateCheck = 1130;
+ LastUpgradeCheck = 1130;
+ ORGANIZATIONNAME = orgName;
+ TargetAttributes = {
+ 7555FF7A242A565900829871 = {
+ CreatedOnToolsVersion = 11.3.1;
+ };
+ };
+ };
+ buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */;
+ compatibilityVersion = "Xcode 12.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 7555FF72242A565900829871;
+ packageReferences = (
+ );
+ productRefGroup = 7555FF7C242A565900829871 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 7555FF7A242A565900829871 /* iosApp */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 7555FF79242A565900829871 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */,
+ 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */ = {
+ isa = PBXShellScriptBuildPhase;
+ 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 :composeApp:embedAndSignAppleFrameworkForXcode\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 7555FF77242A565900829871 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */,
+ 7555FF83242A565900829871 /* ContentView.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 7555FFA3242A565B00829871 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ 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_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ 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 = 15.3;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 7555FFA4242A565B00829871 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ 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;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ 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 = 15.3;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 7555FFA6242A565B00829871 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
+ DEVELOPMENT_TEAM = "${TEAM_ID}";
+ ENABLE_PREVIEWS = YES;
+ FRAMEWORK_SEARCH_PATHS = (
+ "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
+ );
+ INFOPLIST_FILE = iosApp/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.3;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-framework",
+ composeApp,
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
+ PRODUCT_NAME = "${APP_NAME}";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 7555FFA7242A565B00829871 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
+ DEVELOPMENT_TEAM = "${TEAM_ID}";
+ ENABLE_PREVIEWS = YES;
+ FRAMEWORK_SEARCH_PATHS = (
+ "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
+ );
+ INFOPLIST_FILE = iosApp/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.3;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-framework",
+ composeApp,
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
+ PRODUCT_NAME = "${APP_NAME}";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7555FFA3242A565B00829871 /* Debug */,
+ 7555FFA4242A565B00829871 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7555FFA6242A565B00829871 /* Debug */,
+ 7555FFA7242A565B00829871 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 7555FF73242A565900829871 /* Project object */;
+}
diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..ee7e3ca
--- /dev/null
+++ b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..8edf56e
--- /dev/null
+++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "app-icon-1024.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png
new file mode 100644
index 0000000..53fc536
Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png differ
diff --git a/iosApp/iosApp/Assets.xcassets/Contents.json b/iosApp/iosApp/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..4aa7c53
--- /dev/null
+++ b/iosApp/iosApp/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift
new file mode 100644
index 0000000..3cd5c32
--- /dev/null
+++ b/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(.keyboard) // Compose has own keyboard handler
+ }
+}
+
+
+
diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist
new file mode 100644
index 0000000..412e378
--- /dev/null
+++ b/iosApp/iosApp/Info.plist
@@ -0,0 +1,50 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSRequiresIPhoneOS
+
+ CADisableMinimumFrameDurationOnPhone
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ UILaunchScreen
+
+ UIRequiredDeviceCapabilities
+
+ armv7
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 0000000..4aa7c53
--- /dev/null
+++ b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift
new file mode 100644
index 0000000..b7bf2f4
--- /dev/null
+++ b/iosApp/iosApp/iOSApp.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+@main
+struct iOSApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
diff --git a/metadata/screenshots/screenshot-targetshocase-circle.png b/metadata/screenshots/screenshot-targetshocase-circle.png
new file mode 100644
index 0000000..bc4be67
Binary files /dev/null and b/metadata/screenshots/screenshot-targetshocase-circle.png differ
diff --git a/metadata/screenshots/screenshot-targetshocase-rect.png b/metadata/screenshots/screenshot-targetshocase-rect.png
new file mode 100644
index 0000000..ca8ccb0
Binary files /dev/null and b/metadata/screenshots/screenshot-targetshocase-rect.png differ
diff --git a/metadata/screenshots/screenshot-targetshocase-roundrect.png b/metadata/screenshots/screenshot-targetshocase-roundrect.png
new file mode 100644
index 0000000..5922027
Binary files /dev/null and b/metadata/screenshots/screenshot-targetshocase-roundrect.png differ
diff --git a/settings.gradle b/settings.gradle.kts
similarity index 59%
rename from settings.gradle
rename to settings.gradle.kts
index abe2b81..5f892e2 100644
--- a/settings.gradle
+++ b/settings.gradle.kts
@@ -1,9 +1,10 @@
-
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement {
repositories {
google()
gradlePluginPortal()
mavenCentral()
+ maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}
plugins {
@@ -13,9 +14,11 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
+ maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}
-rootProject.name = "Showcase Layout Compose"
+rootProject.name = "Showcase-Layout-Compose-KMP"
include (":app")
include (":showcase-layout-compose")
includeBuild("convention-plugins")
+include (":composeApp")
diff --git a/showcase-layout-compose/build.gradle.kts b/showcase-layout-compose/build.gradle.kts
index 396131e..1f02484 100644
--- a/showcase-layout-compose/build.gradle.kts
+++ b/showcase-layout-compose/build.gradle.kts
@@ -1,10 +1,10 @@
-import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
plugins {
- kotlin("multiplatform")
- id("com.android.library")
- id("org.jetbrains.compose")
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.androidLibrary)
+ alias(libs.plugins.jetbrainsCompose)
+ alias(libs.plugins.compose.compiler)
id("convention.publication")
}
@@ -18,9 +18,9 @@ kotlin {
iosArm64()
iosSimulatorArm64()
- @OptIn(ExperimentalWasmDsl::class)
+ @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
- moduleName = "composeApp"
+ outputModuleName = "composeApp"
browser {
commonWebpackConfig {
outputFileName = "composeApp.js"
@@ -65,7 +65,7 @@ kotlin {
}
android {
- compileSdk = 34
+ compileSdk = 35
namespace = "ly.com.tahaben.showcaselayoutcompose"
compileOptions {
@@ -76,8 +76,3 @@ android {
jvmToolchain(17)
}
}
-
-
-compose.experimental {
- web.application {}
-}
\ No newline at end of file
diff --git a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/model/ShowcaseData.kt b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/model/ShowcaseData.kt
index 4add387..b54c5fb 100644
--- a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/model/ShowcaseData.kt
+++ b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/model/ShowcaseData.kt
@@ -1,6 +1,7 @@
package ly.com.tahaben.showcase_layout_compose.model
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.unit.IntSize
/**
@@ -23,5 +24,6 @@ import androidx.compose.ui.unit.IntSize
data class ShowcaseData(
val size: IntSize,
val position: Offset,
- val message: ShowcaseMsg? = null
+ val message: ShowcaseMsg? = null,
+ val coordinates: LayoutCoordinates? = null
)
diff --git a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/model/TargetShape.kt b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/model/TargetShape.kt
new file mode 100644
index 0000000..00dfecb
--- /dev/null
+++ b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/model/TargetShape.kt
@@ -0,0 +1,12 @@
+package ly.com.tahaben.showcase_layout_compose.model
+
+/**
+ * Enum class that defines the shape of the target highlight in the TargetShowcaseLayout.
+ *
+ * CIRCLE: Draws a circular highlight around the target.
+ * RECTANGLE: Draws a rectangular highlight around the target.
+ * ROUNDED_RECTANGLE: Draws a rounded rectangular highlight around the target.
+ */
+enum class TargetShape {
+ CIRCLE, RECTANGLE, ROUNDED_RECTANGLE
+}
diff --git a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseLayout.kt b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseLayout.kt
index c0a0996..186aeb5 100644
--- a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseLayout.kt
+++ b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseLayout.kt
@@ -12,18 +12,11 @@ import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
+import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
@@ -40,27 +33,17 @@ import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.Constraints
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.toSize
+import androidx.compose.ui.unit.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import ly.com.tahaben.showcase_layout_compose.domain.Level
import ly.com.tahaben.showcase_layout_compose.domain.ShowcaseEventListener
-import ly.com.tahaben.showcase_layout_compose.model.Arrow
-import ly.com.tahaben.showcase_layout_compose.model.Gravity
-import ly.com.tahaben.showcase_layout_compose.model.Head
-import ly.com.tahaben.showcase_layout_compose.model.MsgAnimation
-import ly.com.tahaben.showcase_layout_compose.model.ShowcaseData
-import ly.com.tahaben.showcase_layout_compose.model.ShowcaseMsg
-import ly.com.tahaben.showcase_layout_compose.model.Side
+import ly.com.tahaben.showcase_layout_compose.model.*
import kotlin.math.PI
import kotlin.math.atan2
+import kotlin.math.max
/**
* Copyright 2023 Taha Ben Ashur (tahaak67)
@@ -92,6 +75,8 @@ private const val INDEX_RESET_DELAY = 250L
* @param onFinish what happens when all items are showcased.
* @param greeting greeting message to be shown before showcasing the first composable, leave [initIndex] at 0 if you want to use this.
* @param lineThickness thickness of the arrow line in dp.
+ * @param targetShape the shape of the target highlight (RECTANGLE, CIRCLE, or ROUNDED_RECTANGLE).
+ * @param cornerRadius the corner radius for the ROUNDED_RECTANGLE shape in dp.
**/
@Composable
@@ -103,55 +88,53 @@ fun ShowcaseLayout(
onFinish: () -> Unit,
greeting: ShowcaseMsg? = null,
lineThickness: Dp = 5.dp,
+ targetShape: TargetShape = TargetShape.RECTANGLE,
+ cornerRadius: Dp = 16.dp,
content: @Composable ShowcaseScope.() -> Unit
) {
var currentIndex by remember {
mutableIntStateOf(initIndex)
}
+ val currentContent by rememberUpdatedState(content)
val resetDelay by derivedStateOf { animationDuration.toLong() + INDEX_RESET_DELAY }
val scope = ShowcaseScopeImpl(greeting)
- scope.content()
+ scope.currentContent()
- var showCasingItem by remember { mutableStateOf(false) }
var singleGreetingMsg by remember { mutableStateOf(null) }
- var isSingleGreeting by remember { mutableStateOf(false) }
- LaunchedEffect(key1 = isShowcasing) {
- launch {
- scope.showcaseActionFlow.collectLatest {
- if (it != null) {
- scope.showcaseEventListener?.onEvent(
- Level.DEBUG,
- TAG + "showcase single item index: $it"
- )
- currentIndex = it ?: initIndex
- showCasingItem = true
- } else {
- showCasingItem = false
- delay(resetDelay)
- currentIndex = initIndex
- }
+ val showcaseItem = scope.showcaseActionFlow.collectAsState()
+ val showCasingItem by remember {
+ derivedStateOf {
+ if (showcaseItem.value != null) {
+ scope.showcaseEventListener?.onEvent(
+ Level.DEBUG,
+ TAG + "showcase single item index: ${showcaseItem.value}"
+ )
+ currentIndex = showcaseItem.value ?: initIndex
+ true
+ } else {
+ false
}
}
- launch {
- scope.greetingActionFlow.collectLatest {
- if (it != null) {
- scope.showcaseEventListener?.onEvent(
- Level.DEBUG,
- TAG + "showcase single greeting: $it"
- )
- currentIndex = 0
- isSingleGreeting = true
- } else {
- isSingleGreeting = false
- delay(resetDelay)
- currentIndex = initIndex
- }
- singleGreetingMsg = it
+ }
+ val singleGreeting = scope.greetingActionFlow.collectAsState()
+ val isSingleGreeting by remember {
+ derivedStateOf {
+ if (singleGreeting.value != null) {
+ scope.showcaseEventListener?.onEvent(
+ Level.DEBUG,
+ TAG + "showcase single greeting: ${singleGreeting.value?.text}"
+ )
+ singleGreetingMsg = singleGreeting.value
+ currentIndex = 0
+ true
+ } else {
+ false
}
}
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+ val coroutineScope = rememberCoroutineScope()
AnimatedVisibility(
isShowcasing || showCasingItem || isSingleGreeting,
enter = fadeIn(),
@@ -178,7 +161,6 @@ fun ShowcaseLayout(
val shouldDrawArrow = (message?.arrow != null && isArrowDelayOver)
val arrowColor = message?.arrow?.color ?: Color.White
val density = LocalDensity.current
- val coroutineScope = rememberCoroutineScope()
var arrowAnimDuration by remember { mutableStateOf(message?.arrow?.animationDuration) }
val animMsgTextAlpha = remember { Animatable(0f) }
val animMsgAlpha = remember { Animatable(0f) }
@@ -188,6 +170,7 @@ fun ShowcaseLayout(
/** to animate current arrow line */
LaunchedEffect(key1 = currentIndex) {
+
message = scope.getMessageFor(currentIndex)
arrowAnimDuration = message?.arrow?.animationDuration
isArrowDelayOver = false
@@ -210,7 +193,7 @@ fun ShowcaseLayout(
launch {
message?.arrow?.let { arrow ->
/** show the arrow if anim is false */
- if (!arrow.animSize){
+ if (!arrow.animSize) {
animArrowHead.snapTo(arrow.headSize)
}
/** move the arrow */
@@ -222,7 +205,7 @@ fun ShowcaseLayout(
)
)
/** animate the size of the arrow */
- if (arrow.animSize){
+ if (arrow.animSize) {
animArrowHead.animateTo(
arrow.headSize,
tween(
@@ -274,21 +257,24 @@ fun ShowcaseLayout(
modifier = Modifier
.fillMaxSize()
.semantics { testTag = "canvas" }
- .pointerInput(isShowcasing) {
+ .pointerInput(Unit) {
detectTapGestures {
/** detect taps on the screen */
coroutineScope.launch {
/** hide current arrow */
arrowAnimDuration?.let { duration ->
- if (message?.arrow?.animSize == true){
- animArrowHead.animateTo(0f, tween(duration/2))
+ if (message?.arrow?.animSize == true) {
+ animArrowHead.animateTo(0f, tween(duration / 2))
}
launch {
animArrow.animateTo(0f, tween(duration / 2))
}
// subtracting Float.MIN_VALUE here to avoid a tiny part of the path left on IOS and Desktop
- pathPortion.animateTo(0f - Float.MIN_VALUE, tween(duration / 2))
+ pathPortion.animateTo(
+ 0f - Float.MIN_VALUE,
+ tween(duration / 2)
+ )
}
message?.let { msg ->
scope.showcaseEventListener?.onEvent(
@@ -314,10 +300,14 @@ fun ShowcaseLayout(
}
if (showCasingItem) {
scope.showcaseItemFinished()
+ delay(resetDelay)
+ currentIndex = initIndex
return@launch
}
if (isSingleGreeting) {
scope.showGreetingFinished()
+ delay(resetDelay)
+ currentIndex = initIndex
return@launch
}
if (currentIndex + 1 < scope.getHashMapSize()) {
@@ -348,187 +338,172 @@ fun ShowcaseLayout(
onDraw = {
/** make transparent background path around the target composable */
- val showcasePath = Path().apply {
- lineTo(size.width, 0f)
- lineTo(size.width, size.height)
- lineTo(offset.x + itemSize.width, size.height)
- lineTo(offset.x + itemSize.width, 0f)
- moveTo(offset.x + itemSize.width, offset.y + itemSize.height)
- lineTo(offset.x + itemSize.width, size.height)
- lineTo(0f, size.height)
- lineTo(0f, offset.y + itemSize.height)
- close()
- moveTo(0f, 0f)
- lineTo(offset.x, 0f)
- lineTo(offset.x, offset.y + itemSize.height)
- lineTo(0f, offset.y + itemSize.height)
- close()
- moveTo(offset.x, 0f)
- lineTo(offset.x + itemSize.width, 0f)
- lineTo(offset.x + itemSize.width, offset.y)
- lineTo(offset.x, offset.y)
- close()
- }
- /** draw the showcasePath */
- drawPath(
- path = showcasePath,
- color = if (isDarkLayout) Color.White else Color.Black,
- alpha = 0.80f,
- )
- val hasArrowHead = message?.arrow?.head != null
- val arrowHeadMargin = (message?.arrow?.headSize ?: Arrow().headSize) + 25
-
- if (currentIndex > 0 && shouldDrawArrow) {
- /** draw arrow line */
- val arrowPath = Path().apply {
- if (message?.arrow?.curved == true) {
- moveTo(
- (maxWidth / 2).toPx(),
- offset.y + itemSize.height + 200
- )
- val xPoint = if ((offset.x + itemSize.width + 80) > size.width) {
- offset.x - 80
- } else {
- (offset.x + itemSize.width + 50)
+ if (currentIndex == 0 || isSingleGreeting) {
+ // Draw a full canvas without any cutout for greeting or index 0
+ drawRect(
+ color = if (isDarkLayout) Color.White else Color.Black,
+ alpha = 0.80f,
+ size = size
+ )
+ } else {
+ when (targetShape) {
+ TargetShape.RECTANGLE -> {
+ // Create a rectangular path around the target
+ val showcasePath = Path().apply {
+ lineTo(size.width, 0f)
+ lineTo(size.width, size.height)
+ lineTo(offset.x + itemSize.width, size.height)
+ lineTo(offset.x + itemSize.width, 0f)
+ moveTo(offset.x + itemSize.width, offset.y + itemSize.height)
+ lineTo(offset.x + itemSize.width, size.height)
+ lineTo(0f, size.height)
+ lineTo(0f, offset.y + itemSize.height)
+ close()
+ moveTo(0f, 0f)
+ lineTo(offset.x, 0f)
+ lineTo(offset.x, offset.y + itemSize.height)
+ lineTo(0f, offset.y + itemSize.height)
+ close()
+ moveTo(offset.x, 0f)
+ lineTo(offset.x + itemSize.width, 0f)
+ lineTo(offset.x + itemSize.width, offset.y)
+ lineTo(offset.x, offset.y)
+ close()
}
- quadraticBezierTo(
- (size.width / 2),
- offset.y + itemSize.height + 0,
- xPoint,
- offset.y + (itemSize.height / 2)
+ /** draw the showcasePath */
+ drawPath(
+ path = showcasePath,
+ color = if (isDarkLayout) Color.White else Color.Black,
+ alpha = 0.80f,
)
- } else {
- when (message?.arrow?.targetFrom) {
- Side.Top -> {
- moveTo(
- offset.x + (itemSize.width / 2),
- offset.y - 200
- )
- lineTo(
- offset.x + (itemSize.width / 2),
- if (hasArrowHead) offset.y - arrowHeadMargin else offset.y
- )
- }
-
- Side.Bottom -> {
- moveTo(
- offset.x + (itemSize.width / 2),
- offset.y + (itemSize.height + 250)
- )
- lineTo(
- offset.x + (itemSize.width / 2),
- if (hasArrowHead) offset.y + itemSize.height + arrowHeadMargin else offset.y + itemSize.height
- )
- }
+ }
+ TargetShape.CIRCLE -> {
+ // Calculate the center and radius of the circle
+ val centerX = offset.x + itemSize.width / 2
+ val centerY = offset.y + itemSize.height / 2
+ val radius = maxOf(itemSize.width, itemSize.height) / 2
- Side.Left -> {
- moveTo(
- offset.x - 200,
- offset.y + (itemSize.height / 2)
- )
- lineTo(
- if (hasArrowHead) offset.x - arrowHeadMargin else offset.x,
- offset.y + (itemSize.height / 2)
- )
- }
+ // Create paths for the outer and inner areas
+ val outerPath = Path().apply {
+ // Draw a rectangle covering the entire canvas
+ addRect(Rect(0f, 0f, size.width, size.height))
+ }
- Side.Right -> {
- moveTo(
- offset.x + (itemSize.width + 200),
- offset.y + (itemSize.height / 2)
- )
- lineTo(
- if (hasArrowHead) offset.x + itemSize.width + arrowHeadMargin else offset.x + itemSize.width,
- offset.y + (itemSize.height / 2)
- )
- }
+ // Create a path for the target area (circle)
+ val targetPath = Path().apply {
+ addOval(Rect(
+ centerX - radius,
+ centerY - radius,
+ centerX + radius,
+ centerY + radius
+ ))
+ }
- null -> Unit
+ // Create a combined path with a hole
+ val showcasePath = Path().apply {
+ addPath(outerPath)
+ op(outerPath, targetPath, androidx.compose.ui.graphics.PathOperation.Difference)
}
- }
- }
- val outPath = Path()
- val pos = FloatArray(2)
- val tan = FloatArray(2)
- PathMeasure().apply {
- setPath(arrowPath, false)
- getSegment(0f, pathPortion.value * length, outPath, true)
- getPosition(pathPortion.value * length).apply {
- pos[0] = x
- pos[1] = y
- }
- getTangent(pathPortion.value * length).apply {
- tan[0] = x
- tan[1] = y
+ // Draw the path
+ drawPath(
+ path = showcasePath,
+ color = if (isDarkLayout) Color.White else Color.Black,
+ alpha = 0.80f,
+ )
}
- scope.showcaseEventListener?.onEvent(
- Level.VERBOSE,
- TAG + "pos:${pos} tan:${tan}"
- )
- }
- drawPath(
- path = outPath,
- color = arrowColor,
- style = Stroke(width = lineThickness.toPx(), cap = StrokeCap.Round)
- )
+ TargetShape.ROUNDED_RECTANGLE -> {
+ // Create paths for the outer and inner areas
+ val outerPath = Path().apply {
+ // Draw a rectangle covering the entire canvas
+ addRect(Rect(0f, 0f, size.width, size.height))
+ }
- /** draw the arrow head (and rotate if needed) */
- val arrowSize = animArrowHead.value
- val x = pos[0]
- val y = pos[1]
- val degrees = -atan2(tan[0], tan[1]) * (180f / PI.toFloat()) - 180f
- scope.showcaseEventListener?.onEvent(
- Level.VERBOSE,
- TAG + "max canvas: x:${size.width} y:${size.height}"
- )
- when (message?.arrow?.head) {
- Head.CIRCLE -> {
- drawCircle(
- center = Offset(x,y),
- color = arrowColor,
- alpha = animArrow.value,
- radius = arrowSize
- )
+ // Create a path for the target area (rounded rectangle)
+ val targetPath = Path().apply {
+ // Top-left corner
+ moveTo(offset.x + cornerRadius.toPx(), offset.y)
- }
- Head.TRIANGLE -> {
- rotate(degrees = degrees, pivot = Offset(x, y)) {
- drawPath(
- path = Path().apply {
- moveTo(x, y - arrowSize)
- lineTo(x - arrowSize, y + arrowSize)
- lineTo(x + arrowSize, y + arrowSize)
- close()
- },
- color = arrowColor,
- alpha = animArrow.value
+ // Top edge and top-right corner
+ lineTo(offset.x + itemSize.width - cornerRadius.toPx(), offset.y)
+ arcTo(
+ rect = Rect(
+ offset.x + itemSize.width - 2 * cornerRadius.toPx(),
+ offset.y,
+ offset.x + itemSize.width,
+ offset.y + 2 * cornerRadius.toPx()
+ ),
+ startAngleDegrees = 270f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ // Right edge and bottom-right corner
+ lineTo(offset.x + itemSize.width, offset.y + itemSize.height - cornerRadius.toPx())
+ arcTo(
+ rect = Rect(
+ offset.x + itemSize.width - 2 * cornerRadius.toPx(),
+ offset.y + itemSize.height - 2 * cornerRadius.toPx(),
+ offset.x + itemSize.width,
+ offset.y + itemSize.height
+ ),
+ startAngleDegrees = 0f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ // Bottom edge and bottom-left corner
+ lineTo(offset.x + cornerRadius.toPx(), offset.y + itemSize.height)
+ arcTo(
+ rect = Rect(
+ offset.x,
+ offset.y + itemSize.height - 2 * cornerRadius.toPx(),
+ offset.x + 2 * cornerRadius.toPx(),
+ offset.y + itemSize.height
+ ),
+ startAngleDegrees = 90f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
)
+
+ // Left edge and top-left corner
+ lineTo(offset.x, offset.y + cornerRadius.toPx())
+ arcTo(
+ rect = Rect(
+ offset.x,
+ offset.y,
+ offset.x + 2 * cornerRadius.toPx(),
+ offset.y + 2 * cornerRadius.toPx()
+ ),
+ startAngleDegrees = 180f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ close()
}
- }
- Head.SQUARE -> {
- drawRect(
- topLeft = Offset(x - arrowSize.div(2),y - arrowSize.div(2)),
- color = arrowColor,
- alpha = animArrow.value,
- size = Size(arrowSize,arrowSize)
- )
- }
- Head.ROUND_SQUARE -> {
- val radius = arrowSize.div(4)
- drawRoundRect(
- topLeft = Offset(x - arrowSize.div(2),y - arrowSize.div(2)),
- color = arrowColor,
- alpha = animArrow.value,
- size = Size(arrowSize,arrowSize),
- cornerRadius = CornerRadius(radius,radius)
+
+ // Create a combined path with a hole
+ val showcasePath = Path().apply {
+ addPath(outerPath)
+ op(outerPath, targetPath, androidx.compose.ui.graphics.PathOperation.Difference)
+ }
+
+ // Draw the path
+ drawPath(
+ path = showcasePath,
+ color = if (isDarkLayout) Color.White else Color.Black,
+ alpha = 0.80f,
)
}
- null -> Unit
-
}
-
}
+
+ // Calculate the center and radius of the circle for target
+ val centerX = offset.x + itemSize.width / 2
+ val centerY = offset.y + itemSize.height / 2
+ val radius = maxOf(itemSize.width, itemSize.height) / 2
+
message?.let { msg ->
/**
Create a measurer for the message with limited constraints and a 'Visible'
@@ -539,7 +514,7 @@ fun ShowcaseLayout(
msg.text,
style = msg.textStyle,
overflow = TextOverflow.Visible,
- constraints = Constraints(0, constraints.maxWidth - 90)
+ constraints = Constraints(0, max(1, constraints.maxWidth - 90))
)
/** Determine if message will be shown on top or below target */
@@ -547,24 +522,38 @@ fun ShowcaseLayout(
if (currentIndex == 0) (size.height / 2) else with(density) {
val currentItemYPosition = scope.getPositionFor(currentIndex).y
val currentItemHeight = scope.getSizeFor(currentIndex).height
+
+ // Calculate additional offset for circle shape
+ val additionalOffset = if (targetShape == TargetShape.CIRCLE) {
+ // Use the radius of the circle as additional offset
+ val currentItemWidth = scope.getSizeFor(currentIndex).width
+ val radius = maxOf(currentItemWidth, currentItemHeight) / 2
+ // Add some extra margin (50px) to ensure enough space for the arrow
+ radius + 50
+ } else {
+ 0f
+ }
+
+ val baseOffset = 230 + additionalOffset
+
when (msg.gravity) {
Gravity.Top -> {
- currentItemYPosition - 230
+ currentItemYPosition - baseOffset
}
Gravity.Bottom -> {
- currentItemYPosition + currentItemHeight + 230
+ currentItemYPosition + currentItemHeight + baseOffset
}
Gravity.Auto -> {
val topPosition =
- currentItemYPosition - 230
+ currentItemYPosition - baseOffset
if (topPosition < 0) {
scope.showcaseEventListener?.onEvent(
Level.INFO,
TAG + "index: $currentIndex Not enough space on top show msg on bottom"
)
- currentItemYPosition + currentItemHeight + 230
+ currentItemYPosition + currentItemHeight + baseOffset
} else {
scope.showcaseEventListener?.onEvent(
Level.INFO,
@@ -589,6 +578,18 @@ fun ShowcaseLayout(
} else {
val currentItemXPosition = scope.getPositionFor(currentIndex).x
val currentItemWidth = scope.getSizeFor(currentIndex).width
+ val currentItemHeight = scope.getSizeFor(currentIndex).height
+
+ // Calculate additional horizontal offset for circle shape
+ val additionalHorizontalOffset = if (targetShape == TargetShape.CIRCLE) {
+ // Use the radius of the circle as additional offset
+ val radius = maxOf(currentItemWidth, currentItemHeight) / 2
+ // Add some extra margin to ensure enough space for the arrow
+ radius * 0.3f // 30% of radius as extra margin
+ } else {
+ 0f
+ }
+
val currentItemXMiddlePoint =
currentItemXPosition + (currentItemWidth / 2)
when {
@@ -600,7 +601,12 @@ fun ShowcaseLayout(
if ((currentItemXMiddlePoint - messageWidthHalf) < 0) {
currentItemXPosition
} else {
- currentItemXMiddlePoint - messageWidthHalf
+ // For left side, move message further left
+ if (msg.arrow?.targetFrom == Side.Right && targetShape == TargetShape.CIRCLE) {
+ currentItemXMiddlePoint - messageWidthHalf - additionalHorizontalOffset
+ } else {
+ currentItemXMiddlePoint - messageWidthHalf
+ }
}
}
@@ -620,7 +626,12 @@ fun ShowcaseLayout(
if (currentItemXMiddlePoint + messageWidthHalf > size.width) {
currentItemXPosition + currentItemWidth - textResult.size.width
} else {
- currentItemXMiddlePoint - messageWidthHalf
+ // For right side, move message further right
+ if (msg.arrow?.targetFrom == Side.Left && targetShape == TargetShape.CIRCLE) {
+ currentItemXMiddlePoint - messageWidthHalf + additionalHorizontalOffset
+ } else {
+ currentItemXMiddlePoint - messageWidthHalf
+ }
}
}
}
@@ -632,6 +643,8 @@ fun ShowcaseLayout(
textResult.size.width + 36,
textResult.size.height + 36
).toSize()
+
+ // Draw the message card
if (msg.roundedCorner == 0.dp) {
drawRect(
msg.msgBackground ?: Color.Transparent,
@@ -648,7 +661,273 @@ fun ShowcaseLayout(
alpha = animMsgAlpha.value
)
}
- drawText(textResult, topLeft = textOffset, alpha = animMsgTextAlpha.value)
+
+ // Draw the arrow if needed
+ val hasArrowHead = msg.arrow?.head != null
+ val arrowHeadMargin = (msg.arrow?.headSize ?: Arrow().headSize) + 25
+
+ if (currentIndex > 0 && shouldDrawArrow) {
+ /** draw arrow line */
+ val arrowPath = Path().apply {
+ if (msg.arrow?.curved == true) {
+ // Calculate card center
+ val cardCenterX = cardOffset.x + cardSize.width / 2
+ val cardCenterY = cardOffset.y + cardSize.height / 2
+
+ // Start from the message card based on targetFrom direction
+ when (msg.arrow?.targetFrom) {
+ Side.Top -> {
+ // Start from bottom center of the message card
+ moveTo(
+ cardCenterX,
+ cardOffset.y + cardSize.height
+ )
+ }
+ Side.Bottom -> {
+ // Start from top center of the message card
+ moveTo(
+ cardCenterX,
+ cardOffset.y
+ )
+ }
+ Side.Left -> {
+ // Start from right center of the message card
+ moveTo(
+ cardOffset.x + cardSize.width,
+ cardCenterY
+ )
+ }
+ Side.Right -> {
+ // Start from left center of the message card
+ moveTo(
+ cardOffset.x,
+ cardCenterY
+ )
+ }
+ else -> {
+ // Default to bottom center if targetFrom is not specified
+ moveTo(
+ cardCenterX,
+ cardOffset.y + cardSize.height
+ )
+ }
+ }
+
+ val xPoint =
+ if ((offset.x + itemSize.width + 80) > size.width) {
+ offset.x - 80
+ } else {
+ (offset.x + itemSize.width + 50)
+ }
+ quadraticTo(
+ size.width / 2, offset.y + itemSize.height + 0,
+ xPoint,
+ offset.y + (itemSize.height / 2)
+ )
+ } else {
+ // Calculate card center
+ val cardCenterX = cardOffset.x + cardSize.width / 2
+ val cardCenterY = cardOffset.y + cardSize.height / 2
+
+ when (msg.arrow?.targetFrom) {
+ Side.Top -> {
+ // Start from bottom center of the message card
+ if (targetShape == TargetShape.CIRCLE) {
+ moveTo(
+ cardCenterX,
+ cardOffset.y + cardSize.height
+ )
+ // For circle, point to the top edge of the circle
+ lineTo(
+ centerX,
+ if (hasArrowHead) centerY - radius - arrowHeadMargin else centerY - radius
+ )
+ } else {
+ moveTo(
+ offset.x + (itemSize.width / 2),
+ offset.y - 180
+ )
+ lineTo(
+ offset.x + (itemSize.width / 2),
+ if (hasArrowHead) offset.y - arrowHeadMargin else offset.y
+ )
+ }
+ }
+
+ Side.Bottom -> {
+ // Start from top center of the message card
+ if (targetShape == TargetShape.CIRCLE) {
+ moveTo(
+ cardCenterX,
+ cardOffset.y
+ )
+ // For circle, point to the bottom edge of the circle
+ lineTo(
+ centerX,
+ if (hasArrowHead) centerY + radius + arrowHeadMargin else centerY + radius
+ )
+ } else {
+ moveTo(
+ offset.x + (itemSize.width / 2),
+ offset.y + (itemSize.height + 200)
+ )
+ lineTo(
+ offset.x + (itemSize.width / 2),
+ if (hasArrowHead) offset.y + itemSize.height + arrowHeadMargin else offset.y + itemSize.height
+ )
+ }
+ }
+
+ Side.Left -> {
+ // Start from right center of the message card
+ if (targetShape == TargetShape.CIRCLE) {
+ moveTo(
+ cardOffset.x + cardSize.width,
+ cardCenterY
+ )
+ // For circle, point to the left edge of the circle
+ lineTo(
+ if (hasArrowHead) centerX - radius - arrowHeadMargin else centerX - radius,
+ centerY
+ )
+ } else {
+ moveTo(
+ offset.x - 200,
+ offset.y + (itemSize.height / 2)
+ )
+ lineTo(
+ if (hasArrowHead) offset.x - arrowHeadMargin else offset.x,
+ offset.y + (itemSize.height / 2)
+ )
+ }
+ }
+
+ Side.Right -> {
+ // Start from left center of the message card
+ if (targetShape == TargetShape.CIRCLE) {
+ moveTo(
+ cardOffset.x,
+ cardCenterY
+ )
+ // For circle, point to the right edge of the circle
+ lineTo(
+ if (hasArrowHead) centerX + radius + arrowHeadMargin else centerX + radius,
+ centerY
+ )
+ } else {
+ moveTo(
+ offset.x + (itemSize.width + 200),
+ offset.y + (itemSize.height / 2)
+ )
+ lineTo(
+ if (hasArrowHead) offset.x + itemSize.width + arrowHeadMargin else offset.x + itemSize.width,
+ offset.y + (itemSize.height / 2)
+ )
+ }
+ }
+
+ null -> Unit
+ }
+ }
+ }
+
+ val outPath = Path()
+ val pos = FloatArray(2)
+ val tan = FloatArray(2)
+ PathMeasure().apply {
+ setPath(arrowPath, false)
+ getSegment(0f, pathPortion.value * length, outPath, true)
+ getPosition(pathPortion.value * length).apply {
+ pos[0] = x
+ pos[1] = y
+ }
+ getTangent(pathPortion.value * length).apply {
+ tan[0] = x
+ tan[1] = y
+ }
+ scope.showcaseEventListener?.onEvent(
+ Level.VERBOSE,
+ TAG + "pos:${pos} tan:${tan}"
+ )
+ }
+ drawPath(
+ path = outPath,
+ color = arrowColor,
+ style = Stroke(width = lineThickness.toPx(), cap = StrokeCap.Round)
+ )
+
+ /** draw the arrow head (and rotate if needed) */
+ val arrowSize = animArrowHead.value
+ val x = pos[0]
+ val y = pos[1]
+ val degrees = -atan2(tan[0], tan[1]) * (180f / PI.toFloat()) - 180f
+ scope.showcaseEventListener?.onEvent(
+ Level.VERBOSE,
+ TAG + "max canvas: x:${size.width} y:${size.height}"
+ )
+ when (msg.arrow?.head) {
+ Head.CIRCLE -> {
+ drawCircle(
+ center = Offset(x, y),
+ color = arrowColor,
+ alpha = animArrow.value,
+ radius = arrowSize
+ )
+
+ }
+
+ Head.TRIANGLE -> {
+ rotate(degrees = degrees, pivot = Offset(x, y)) {
+ drawPath(
+ path = Path().apply {
+ moveTo(x, y - arrowSize)
+ lineTo(x - arrowSize, y + arrowSize)
+ lineTo(x + arrowSize, y + arrowSize)
+ close()
+ },
+ color = arrowColor,
+ alpha = animArrow.value
+ )
+ }
+ }
+
+ Head.SQUARE -> {
+ drawRect(
+ topLeft = Offset(
+ x - arrowSize.div(2),
+ y - arrowSize.div(2)
+ ),
+ color = arrowColor,
+ alpha = animArrow.value,
+ size = Size(arrowSize, arrowSize)
+ )
+ }
+
+ Head.ROUND_SQUARE -> {
+ val radius = arrowSize.div(4)
+ drawRoundRect(
+ topLeft = Offset(
+ x - arrowSize.div(2),
+ y - arrowSize.div(2)
+ ),
+ color = arrowColor,
+ alpha = animArrow.value,
+ size = Size(arrowSize, arrowSize),
+ cornerRadius = CornerRadius(radius, radius)
+ )
+ }
+
+ null -> Unit
+
+ }
+ }
+
+ // Draw the message text
+ drawText(
+ textResult,
+ topLeft = textOffset,
+ alpha = animMsgTextAlpha.value
+ )
}
}
)
@@ -657,6 +936,7 @@ fun ShowcaseLayout(
TAG + "calc: ${offset.y + itemSize.height - (maxHeight.value / 2)}"
)
}
+
}
}
@@ -676,7 +956,7 @@ class ShowcaseScopeImpl(greeting: ShowcaseMsg?) : ShowcaseScope {
) {
require(index >= 1) { "Index must be 1 or greater" }
Box(modifier = Modifier.onGloballyPositioned {
- showcaseDataHashMap[index] = ShowcaseData(it.size, it.positionInRoot(), message)
+ showcaseDataHashMap[index] = ShowcaseData(it.size, it.positionInRoot(), message, it)
showcaseEventListener?.onEvent(Level.VERBOSE, TAG + "Index: $index")
showcaseEventListener?.onEvent(
@@ -699,7 +979,8 @@ class ShowcaseScopeImpl(greeting: ShowcaseMsg?) : ShowcaseScope {
return this.then(
onGloballyPositioned {
if (it.isAttached) {
- showcaseDataHashMap[index] = ShowcaseData(it.size, it.positionInRoot(), message)
+ showcaseDataHashMap[index] =
+ ShowcaseData(it.size, it.positionInRoot(), message, it)
showcaseEventListener?.onEvent(Level.VERBOSE, TAG + "Index: $index")
showcaseEventListener?.onEvent(
Level.VERBOSE,
@@ -723,7 +1004,7 @@ class ShowcaseScopeImpl(greeting: ShowcaseMsg?) : ShowcaseScope {
_showcaseActionFlow.emit(index)
}
- suspend fun showcaseItemFinished() {
+ suspend fun showcaseItemFinished() {
showcaseEventListener?.onEvent(Level.DEBUG, TAG + "showcase item finished")
_showcaseActionFlow.emit(null)
}
@@ -736,7 +1017,7 @@ class ShowcaseScopeImpl(greeting: ShowcaseMsg?) : ShowcaseScope {
)
}
- suspend fun showGreetingFinished() {
+ suspend fun showGreetingFinished() {
_greetingActionFlow.emit(null)
showcaseEventListener?.onEvent(
Level.DEBUG,
diff --git a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseScope.kt b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseScope.kt
index f9b1a1c..4d11903 100644
--- a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseScope.kt
+++ b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseScope.kt
@@ -52,7 +52,5 @@ interface ShowcaseScope {
fun registerEventListener(eventListener: ShowcaseEventListener)
suspend fun showcaseItem(index: Int)
-// suspend fun showcaseItemFinished()
suspend fun showGreeting(message: ShowcaseMsg)
-// suspend fun showGreetingFinished()
}
\ No newline at end of file
diff --git a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/TargetShowcaseLayout.kt b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/TargetShowcaseLayout.kt
new file mode 100644
index 0000000..57efaf2
--- /dev/null
+++ b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/TargetShowcaseLayout.kt
@@ -0,0 +1,1144 @@
+package ly.com.tahaben.showcase_layout_compose.ui
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.*
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.*
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathOperation
+import androidx.compose.ui.graphics.drawscope.Fill
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTag
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import ly.com.tahaben.showcase_layout_compose.domain.Level
+import ly.com.tahaben.showcase_layout_compose.model.MsgAnimation
+import ly.com.tahaben.showcase_layout_compose.model.ShowcaseMsg
+import ly.com.tahaben.showcase_layout_compose.model.TargetShape
+import kotlin.math.max
+import kotlin.math.min
+
+
+/**
+ * Copyright 2025 Taha Ben Ashur (tahaak67)
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Created by Taha Ben Ashur (https://github.com/tahaak67) on 20,June,2025
+ */
+
+private const val TAG = "TargetShowcaseLayout "
+private const val INDEX_RESET_DELAY = 250L
+
+/**
+ * TargetShowcaseLayout
+ *
+ * @param isShowcasing to determine if showcase is starting or not.
+ * @param isDarkLayout if true the showcase view will be white instead of black.
+ * @param initIndex the initial value of counter, set this to 1 if you don't want a greeting screen before showcasing target.
+ * @param animationDuration total animation time taken when switching from current to next target in milliseconds.
+ * @param onFinish what happens when all items are showcased.
+ * @param greeting greeting message to be shown before showcasing the first composable, leave [initIndex] at 0 if you want to use this.
+ * @param lineThickness thickness of the arrow line in dp.
+ * @param targetShape the shape of the target highlight, can be either CIRCLE, RECTANGLE, or ROUNDED_RECTANGLE.
+ * @param cornerRadius the radius of the corners when targetShape is ROUNDED_RECTANGLE.
+ * @param animateToNextTarget if true, the target shape will animate smoothly from one target to the next when the index changes.
+ * If false, the shape will shrink at the current location, then expand at the new location.
+ **/
+@Composable
+fun TargetShowcaseLayout(
+ isShowcasing: Boolean,
+ isDarkLayout: Boolean = false,
+ initIndex: Int = 0,
+ animationDuration: Int = 1000,
+ onFinish: () -> Unit,
+ greeting: ShowcaseMsg? = null,
+ lineThickness: Dp = 5.dp,
+ targetShape: TargetShape = TargetShape.ROUNDED_RECTANGLE,
+ cornerRadius: Dp = 8.dp,
+ animateToNextTarget: Boolean = true,
+ content: @Composable ShowcaseScope.() -> Unit
+) {
+ var currentIndex by remember {
+ mutableIntStateOf(initIndex)
+ }
+ val currentContent by rememberUpdatedState(content)
+ val scope = ShowcaseScopeImpl(greeting)
+ scope.currentContent()
+ val localDensity = LocalDensity.current
+ var singleGreetingMsg by remember { mutableStateOf(null) }
+ val showcaseItem = scope.showcaseActionFlow.collectAsState()
+ val showCasingItem by remember {
+ derivedStateOf {
+ if (showcaseItem.value != null) {
+ scope.showcaseEventListener?.onEvent(
+ Level.DEBUG,
+ TAG + "showcase single item index: ${showcaseItem.value}"
+ )
+ currentIndex = showcaseItem.value ?: initIndex
+ true
+ } else {
+ false
+ }
+ }
+ }
+ val singleGreeting = scope.greetingActionFlow.collectAsState()
+ val isSingleGreeting by remember {
+ derivedStateOf {
+ if (singleGreeting.value != null) {
+ scope.showcaseEventListener?.onEvent(
+ Level.DEBUG,
+ TAG + "showcase single greeting: ${singleGreeting.value?.text}"
+ )
+ singleGreetingMsg = singleGreeting.value
+ currentIndex = 0
+ true
+ } else {
+ false
+ }
+ }
+ }
+
+ BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+ val coroutineScope = rememberCoroutineScope()
+ if (isShowcasing || showCasingItem || isSingleGreeting) {
+ val itemSize = scope.getSizeFor(currentIndex)
+ val offset = scope.getPositionFor(currentIndex)
+ val animatedWidth = remember { Animatable(itemSize.width) }
+ val animatedHeight = remember { Animatable(itemSize.height) }
+
+ val animatedX = remember { Animatable(offset.x) }
+ val animatedY = remember { Animatable(offset.y) }
+ val maxDimension =
+ max(maxHeight.value, maxWidth.value)
+
+ val outerAnimatable = remember { Animatable(0.0f) }
+
+ val message = if (isSingleGreeting) singleGreetingMsg else scope.getMessageFor(currentIndex)
+ val textMeasurer = rememberTextMeasurer()
+ val messageTextAlpha = remember { Animatable(0f) }
+ val canvasColor = message?.msgBackground ?: Color.Black.copy(alpha = 0.9f)
+ val canvasColorAnimated by animateColorAsState(
+ canvasColor,
+ animationSpec = tween(durationMillis = animationDuration, easing = FastOutSlowInEasing)
+ )
+
+ // Animation for overall canvas alpha to make the circle completely disappear
+ val canvasAlpha = remember { Animatable(0f) }
+
+ // Animation for the pulse radius
+ val pulseRadius = remember { Animatable(0f) }
+ // Animation for the pulse transparency
+ val pulseAlpha = remember { Animatable(0.6f) }
+
+ LaunchedEffect(currentIndex) {
+ val pulseDuration = 1200
+ while (true) {
+ pulseAlpha.snapTo(0.6f)
+ pulseRadius.snapTo(0f)
+ launch {
+ pulseAlpha.animateTo(
+ targetValue = 0.0f,
+ animationSpec = tween(
+ durationMillis = pulseDuration,
+ easing = FastOutSlowInEasing,
+ )
+ )
+ }
+ launch {
+ pulseRadius.animateTo(
+ targetValue = maxDimension, // Maximum additional radius of the pulse
+ animationSpec = infiniteRepeatable(
+ animation = tween(
+ pulseDuration,
+ easing = FastOutLinearInEasing
+ ), // 1-second animation
+ repeatMode = RepeatMode.Restart
+ )
+ )
+ }
+ delay(pulseDuration.toLong())
+ }
+ }
+
+ LaunchedEffect(currentIndex) {
+
+ if (currentIndex == 0 || isSingleGreeting) {
+ //canvasAlpha.snapTo(0f)
+ canvasAlpha.animateTo(
+ 1f,
+ animationSpec = tween(durationMillis = animationDuration / 2, easing = FastOutSlowInEasing)
+ )
+ }
+ // If this is the first showcase or we're resetting, snap to initial values
+ if (currentIndex == 0 || currentIndex == initIndex) {
+ delay(animationDuration.toLong())
+ handleMessageEnterAnimation(message, messageTextAlpha, animationDuration)
+ } else {
+ if (animateToNextTarget && !showCasingItem) {
+ outerAnimatable.snapTo(1f)
+ // Reset canvas alpha for the new target
+ canvasAlpha.snapTo(1f)
+
+ // Animate the position of the inner target highlight
+ launch {
+ animatedX.animateTo(
+ offset.x,
+ animationSpec = tween(
+ durationMillis = animationDuration,
+ easing = FastOutSlowInEasing
+ )
+ )
+ }
+ launch {
+ animatedY.animateTo(
+ offset.y,
+ animationSpec = tween(
+ durationMillis = animationDuration,
+ easing = FastOutSlowInEasing
+ )
+ )
+ }
+
+ // Animate the size of the inner target highlight
+ // Use a single coroutine to ensure width and height animations are synchronized
+ launch {
+ // Animate both width and height together with the same duration and easing
+ // This ensures the shape remains circular during the animation
+ animatedWidth.animateTo(
+ itemSize.width,
+ animationSpec = tween(
+ durationMillis = animationDuration,
+ easing = FastOutSlowInEasing
+ )
+ )
+
+ animatedHeight.animateTo(
+ itemSize.height,
+ animationSpec = tween(
+ durationMillis = animationDuration,
+ easing = FastOutSlowInEasing
+ )
+ )
+ }
+
+ } else {
+ animatedX.snapTo(offset.x)
+ animatedY.snapTo(offset.y)
+ animatedHeight.snapTo(0f)
+ animatedWidth.snapTo(0f)
+
+ launch {
+ animatedHeight.animateTo(itemSize.height)
+ }
+ launch {
+ animatedWidth.animateTo(itemSize.width)
+ }
+
+ delay(animationDuration.toLong() / 3)
+ canvasAlpha.animateTo(1f)
+
+ // Expand at new location
+ val expandDuration = animationDuration / 3
+
+ // Animate the outer circle back in
+ launch {
+ outerAnimatable.animateTo(
+ 1f,
+ animationSpec = tween(
+ durationMillis = expandDuration,
+ easing = FastOutSlowInEasing
+ )
+ )
+ }
+
+ }
+
+ // After the animation has completed, fade the message text back in
+ launch {
+ delay(animationDuration.toLong())
+ handleMessageEnterAnimation(message, messageTextAlpha, animationDuration)
+ }
+ }
+ }
+ LaunchedEffect(isShowcasing) {
+ if (isShowcasing && currentIndex != 0 && !isSingleGreeting) {
+ pulseAlpha.snapTo(0.6f)
+ pulseRadius.snapTo(0f)
+ }
+ }
+
+ Canvas(
+ modifier = Modifier.fillMaxSize()
+ .semantics { testTag = "circleModeCanvas" }
+ .pointerInput(Unit) {
+ detectTapGestures {
+ scope.showcaseEventListener?.onEvent(
+ Level.VERBOSE,
+ TAG + "tapped here $it"
+ )
+ if (showCasingItem) {
+ val shrinkDuration = animationDuration
+
+ // Shrink both width and height simultaneously and shrink the outer circle
+ coroutineScope.launch {
+ handleMessageExitAnimation(message, messageTextAlpha, animationDuration)
+ // Shrink the outer circle
+ launch {
+ outerAnimatable.animateTo(
+ 0f,
+ animationSpec = tween(
+ durationMillis = shrinkDuration,
+ easing = FastOutSlowInEasing
+ )
+ )
+ }
+
+ // Fade out the entire canvas to make the circle completely disappear
+ launch {
+ canvasAlpha.animateTo(
+ 0f,
+ animationSpec = tween(
+ durationMillis = shrinkDuration,
+ easing = FastOutSlowInEasing
+ )
+ )
+ }
+
+ // Wait for animations to complete
+ delay((animationDuration / 3).toLong())
+
+ // Finish showcasing the single item
+ scope.showcaseItemFinished()
+ currentIndex = initIndex
+ }
+ return@detectTapGestures
+ } else if (isSingleGreeting) {
+ coroutineScope.launch {
+ handleMessageExitAnimation(singleGreetingMsg, messageTextAlpha, animationDuration)
+
+ // Fade out the entire canvas to make the circle completely disappear
+ launch {
+ canvasAlpha.animateTo(
+ 0f,
+ animationSpec = tween(
+ durationMillis = animationDuration / 3,
+ easing = FastOutSlowInEasing
+ )
+ )
+ }
+
+ // Wait for animations to complete
+ delay((animationDuration / 3).toLong())
+
+ // Finish showcasing the greeting
+ scope.showGreetingFinished()
+
+ currentIndex = initIndex
+ }
+ return@detectTapGestures
+ } else if (currentIndex + 1 < scope.getHashMapSize()) {
+ if (!animateToNextTarget) {
+ // Shrink at current location, then move to new location, then expand
+ // Step 1: Shrink at current location
+ val shrinkDuration = animationDuration
+
+ // Shrink both width and height simultaneously and shrink the outer circle
+ coroutineScope.launch {
+ handleMessageExitAnimation(message, messageTextAlpha, animationDuration)
+
+ // Shrink the outer circle
+ launch {
+ outerAnimatable.animateTo(
+ 0f,
+ animationSpec = tween(
+ durationMillis = shrinkDuration,
+ easing = FastOutSlowInEasing
+ )
+ )
+ }
+
+ // Fade out the entire canvas to make the circle completely disappear
+ launch {
+ canvasAlpha.animateTo(
+ 0f,
+ animationSpec = tween(
+ durationMillis = shrinkDuration,
+ easing = FastOutSlowInEasing
+ )
+ )
+ }
+
+ // Wait for animations to complete
+ delay(shrinkDuration.toLong())
+
+ // Move to the next target
+ currentIndex++
+
+ }
+
+ } else {
+ // When animateToNextTarget is true, we don't want to fade out the outer circle
+ // we just handle message animation if any then move to the next target
+ coroutineScope.launch {
+ handleMessageExitAnimation(message, messageTextAlpha, animationDuration)
+
+ // Move to the next target
+ currentIndex++
+ }
+ }
+
+ } else {
+ // This is the last target, finish the showcase
+
+ coroutineScope.launch {
+ val shrinkDuration = animationDuration / 2
+ handleMessageExitAnimation(singleGreetingMsg, messageTextAlpha, animationDuration)
+
+ // Fade out the entire canvas to make the circle completely disappear
+ launch {
+ canvasAlpha.animateTo(
+ 0f,
+ animationSpec = tween(
+ durationMillis = shrinkDuration,
+ easing = FastOutSlowInEasing
+ )
+ )
+ }
+
+ // Wait for animations to complete
+ delay(shrinkDuration.toLong())
+
+ // Reset index and call onFinish
+ currentIndex = initIndex
+ onFinish()
+ }
+
+ }
+ }
+ }
+ ) {
+ if (isSingleGreeting || currentIndex == 0) {
+ // For greeting, fill the entire screen with a solid color
+ drawRect(
+ color = canvasColor.copy(alpha = 0.9f),
+ size = size,
+ alpha = canvasAlpha.value
+ )
+
+ // Display the greeting message in the middle of the screen
+ message?.let { msg ->
+ // Measure text with appropriate constraints to ensure it wraps if needed
+ val maxTextWidth = max(1, (size.width * 0.8f).toInt()) // Use 80% of screen width
+ val textResult = textMeasurer.measure(
+ msg.text,
+ style = msg.textStyle,
+ overflow = TextOverflow.Visible,
+ constraints = Constraints(0, maxTextWidth)
+ )
+
+ // Center the text on the screen
+ val textX = (size.width - textResult.size.width) / 2
+ val textY = (size.height - textResult.size.height) / 2
+
+ // Draw a background for the text with padding
+ val bgPadding = 40f
+ val bgRect = Rect(
+ left = textX - bgPadding,
+ top = textY - bgPadding,
+ right = textX + textResult.size.width + bgPadding,
+ bottom = textY + textResult.size.height + bgPadding
+ )
+
+ // Draw the text background
+ if (msg.roundedCorner == 0.dp) {
+ drawRect(
+ color = msg.msgBackground ?: Color.Transparent,
+ topLeft = Offset(bgRect.left, bgRect.top),
+ size = Size(bgRect.width, bgRect.height),
+ alpha = messageTextAlpha.value
+ )
+ } else {
+ drawRoundRect(
+ color = msg.msgBackground ?: Color.Transparent,
+ topLeft = Offset(bgRect.left, bgRect.top),
+ size = Size(bgRect.width, bgRect.height),
+ cornerRadius = CornerRadius(msg.roundedCorner.value),
+ alpha = messageTextAlpha.value
+ )
+ }
+
+ // Draw the text
+ drawText(
+ textResult,
+ topLeft = Offset(textX, textY),
+ alpha = messageTextAlpha.value
+ )
+ }
+
+ // Return early to avoid drawing the target shape
+ return@Canvas
+ }
+ // Calculate the radius for the target shape
+ // For a circle, this is the actual radius
+ // For a rectangle, we'll use the actual width and height
+ val circleRadius = max(itemSize.width, itemSize.height).div(2)
+ // Half width and height for rectangle mode
+ val rectHalfWidth = itemSize.width.div(2)
+ val rectHalfHeight = itemSize.height.div(2)
+ val itemCenter = Offset(
+ animatedX.value + itemSize.width.div(2),
+ animatedY.value + itemSize.height.div(2)
+ )
+
+ // Constants for padding and spacing
+ val safetyPadding = 45f
+ val horizontalSafetyPadding = safetyPadding * 1.5f
+
+
+ // Initial calculation of outer circle dimensions (will be adjusted based on text)
+ // Set the outer circle radius to half of the canvas width
+ val outerRadius = max(size.width, size.height) / 2
+ val outerLeft = itemCenter.x - outerRadius
+ val outerTop = itemCenter.y - outerRadius
+ val outerRight = itemCenter.x + outerRadius
+ val outerBottom = itemCenter.y + outerRadius
+
+ // Variables to store text measurement and position
+ var textX = 0f
+ var textWidth = 0
+ var textHeight = 0
+ var textResult: TextLayoutResult? = null
+
+ // Calculate text dimensions and position first, then adjust outer circle if needed
+ message?.let { msg ->
+ // First, calculate an initial estimate of available width for text
+ // This is a conservative estimate that will be refined
+ val initialMaxWidth =
+ min(size.width.toInt() - 200, (outerRight - outerLeft - 4 * safetyPadding).toInt())
+ val maxTextWidth = max(1, initialMaxWidth)
+
+ // Measure text with appropriate constraints
+ textResult = textMeasurer.measure(
+ msg.text,
+ style = msg.textStyle,
+ overflow = TextOverflow.Visible,
+ constraints = Constraints(0, maxTextWidth)
+ )
+
+ // Store text dimensions
+ textWidth = textResult.size.width
+ textHeight = textResult.size.height
+
+
+ // Calculate horizontal position (centered by default)
+ textX = itemCenter.x - textWidth / 2
+
+ // Ensure text stays within screen bounds horizontally
+ if (textX < horizontalSafetyPadding) {
+ textX = horizontalSafetyPadding
+ scope.showcaseEventListener?.onEvent(
+ Level.INFO,
+ TAG + "Text would extend beyond left edge of screen. Moving text right."
+ )
+ }
+
+ if (textX + textWidth > size.width - horizontalSafetyPadding) {
+ scope.showcaseEventListener?.onEvent(
+ Level.INFO,
+ TAG + "Text would extend beyond right edge of screen. Moving text left."
+ )
+ }
+
+ }
+
+ // Create the donut path after all adjustments to outer circle dimensions
+ // Calculate the center of the outer circle
+ val outerCenterX = itemCenter.x//(outerLeft + outerRight) / 2
+ val outerCenterY = itemCenter.y//(outerTop + outerBottom) / 2
+
+ // Calculate the width and height of the outer circle
+ val outerWidth = outerRight - outerLeft
+ val outerHeight = outerBottom - outerTop
+
+ // Apply the scale animation to the outer circle
+ val scaledOuterWidth = outerWidth * outerAnimatable.value
+ val scaledOuterHeight = outerHeight * outerAnimatable.value
+
+ // Calculate the new bounds of the scaled outer circle
+ val scaledOuterLeft = outerCenterX - scaledOuterWidth / 2
+ val scaledOuterTop = outerCenterY - scaledOuterHeight / 2
+ val scaledOuterRight = outerCenterX + scaledOuterWidth / 2
+ val scaledOuterBottom = outerCenterY + scaledOuterHeight / 2
+
+ val donutPath = Path().apply {
+ op(
+ Path().apply {
+ // Outer oval (outer circle) - potentially adjusted for text and scaled
+ addOval(
+ Rect(
+ left = scaledOuterLeft,
+ top = scaledOuterTop,
+ right = scaledOuterRight,
+ bottom = scaledOuterBottom
+ )
+ )
+ },
+ Path().apply {
+ // Inner shape - always at the target
+ // Use either a circle, rectangle, or rounded rectangle based on the targetShape parameter
+ when (targetShape) {
+ TargetShape.CIRCLE -> {
+ // Draw a circle for the inner cutout
+ addOval(
+ Rect(
+ left = itemCenter.x - circleRadius,
+ top = itemCenter.y - circleRadius,
+ right = itemCenter.x + circleRadius,
+ bottom = itemCenter.y + circleRadius
+ )
+ )
+ }
+
+ TargetShape.ROUNDED_RECTANGLE -> {
+ // Draw a rounded rectangle for the inner cutout
+ val rect = Rect(
+ left = itemCenter.x - rectHalfWidth,
+ top = itemCenter.y - rectHalfHeight,
+ right = itemCenter.x + rectHalfWidth,
+ bottom = itemCenter.y + rectHalfHeight
+ )
+ RoundRect(
+ left = itemCenter.x - rectHalfWidth,
+ top = itemCenter.y - rectHalfHeight,
+ right = itemCenter.x + rectHalfWidth,
+ bottom = itemCenter.y + rectHalfHeight,
+ )
+
+ val cornerRadiusPx = with(localDensity) {
+ cornerRadius.toPx()
+ }
+
+ // Top-left arc
+ moveTo(rect.left, rect.top + cornerRadiusPx)
+ arcTo(
+ rect = Rect(
+ left = rect.left,
+ top = rect.top,
+ right = rect.left + cornerRadiusPx * 2,
+ bottom = rect.top + cornerRadiusPx * 2
+ ),
+ startAngleDegrees = 180f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ // Top-right arc
+ lineTo(rect.right - cornerRadiusPx, rect.top)
+ arcTo(
+ rect = Rect(
+ left = rect.right - cornerRadiusPx * 2,
+ top = rect.top,
+ right = rect.right,
+ bottom = rect.top + cornerRadiusPx * 2
+ ),
+ startAngleDegrees = 270f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ // Bottom-right arc
+ lineTo(rect.right, rect.bottom - cornerRadiusPx)
+ arcTo(
+ rect = Rect(
+ left = rect.right - cornerRadiusPx * 2,
+ top = rect.bottom - cornerRadiusPx * 2,
+ right = rect.right,
+ bottom = rect.bottom
+ ),
+ startAngleDegrees = 0f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ // Bottom-left arc
+ lineTo(rect.left + cornerRadiusPx, rect.bottom)
+ arcTo(
+ rect = Rect(
+ left = rect.left,
+ top = rect.bottom - cornerRadiusPx * 2,
+ right = rect.left + cornerRadiusPx * 2,
+ bottom = rect.bottom
+ ),
+ startAngleDegrees = 90f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ close()
+ }
+
+ else -> {
+ // Draw a rectangle for the inner cutout that matches the target's exact dimensions
+ addRect(
+ Rect(
+ left = itemCenter.x - rectHalfWidth,
+ top = itemCenter.y - rectHalfHeight,
+ right = itemCenter.x + rectHalfWidth,
+ bottom = itemCenter.y + rectHalfHeight
+ )
+ )
+ }
+ }
+ },
+ operation = PathOperation.Difference
+ )
+ }
+
+ // Draw the donut path with the dimensions that have been adjusted for text
+ // Apply canvasAlpha to make the circle completely disappear during transitions
+ drawPath(
+ path = donutPath,
+ color = canvasColorAnimated.copy(alpha = 0.9f * canvasAlpha.value),
+ style = Fill // Fill the donut shape
+ )
+
+ // Draw the pulsing ring (outside the punch)
+ val pulsePath = Path().apply {
+ op(
+ Path().apply {
+ // Use either a circle, rectangle, or rounded rectangle for the outer pulse based on the targetShape parameter
+ // This ensures that the pulse animation matches the shape of the target
+ when (targetShape) {
+ TargetShape.CIRCLE -> {
+ // Draw a circle for the outer pulse
+ addOval(
+ Rect(
+ left = itemCenter.x - (circleRadius + pulseRadius.value),
+ top = itemCenter.y - (circleRadius + pulseRadius.value),
+ right = itemCenter.x + (circleRadius + pulseRadius.value),
+ bottom = itemCenter.y + (circleRadius + pulseRadius.value)
+ )
+ )
+ }
+
+ TargetShape.ROUNDED_RECTANGLE -> {
+ // Draw a rounded rectangle for the outer pulse
+ val rect = Rect(
+ left = itemCenter.x - (rectHalfWidth + pulseRadius.value),
+ top = itemCenter.y - (rectHalfHeight + pulseRadius.value),
+ right = itemCenter.x + (rectHalfWidth + pulseRadius.value),
+ bottom = itemCenter.y + (rectHalfHeight + pulseRadius.value)
+ )
+ val cornerRadiusPx = cornerRadius.toPx()
+
+ // Top-left arc
+ moveTo(rect.left, rect.top + cornerRadiusPx)
+ arcTo(
+ rect = Rect(
+ left = rect.left,
+ top = rect.top,
+ right = rect.left + cornerRadiusPx * 2,
+ bottom = rect.top + cornerRadiusPx * 2
+ ),
+ startAngleDegrees = 180f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ // Top-right arc
+ lineTo(rect.right - cornerRadiusPx, rect.top)
+ arcTo(
+ rect = Rect(
+ left = rect.right - cornerRadiusPx * 2,
+ top = rect.top,
+ right = rect.right,
+ bottom = rect.top + cornerRadiusPx * 2
+ ),
+ startAngleDegrees = 270f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ // Bottom-right arc
+ lineTo(rect.right, rect.bottom - cornerRadiusPx)
+ arcTo(
+ rect = Rect(
+ left = rect.right - cornerRadiusPx * 2,
+ top = rect.bottom - cornerRadiusPx * 2,
+ right = rect.right,
+ bottom = rect.bottom
+ ),
+ startAngleDegrees = 0f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ // Bottom-left arc
+ lineTo(rect.left + cornerRadiusPx, rect.bottom)
+ arcTo(
+ rect = Rect(
+ left = rect.left,
+ top = rect.bottom - cornerRadiusPx * 2,
+ right = rect.left + cornerRadiusPx * 2,
+ bottom = rect.bottom
+ ),
+ startAngleDegrees = 90f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ close()
+ }
+
+ else -> {
+ // Draw a rectangle for the outer pulse that matches the target's exact dimensions
+ addRect(
+ Rect(
+ left = itemCenter.x - (rectHalfWidth + pulseRadius.value),
+ top = itemCenter.y - (rectHalfHeight + pulseRadius.value),
+ right = itemCenter.x + (rectHalfWidth + pulseRadius.value),
+ bottom = itemCenter.y + (rectHalfHeight + pulseRadius.value)
+ )
+ )
+ }
+ }
+ },
+ Path().apply {
+ // Use either a circle, rectangle, or rounded rectangle for the inner pulse based on the targetShape parameter
+ // This ensures that the inner cutout of the pulse animation matches the shape of the target
+ when (targetShape) {
+ TargetShape.CIRCLE -> {
+ // Draw a circle for the inner pulse
+ addOval(
+ Rect(
+ left = itemCenter.x - circleRadius,
+ top = itemCenter.y - circleRadius,
+ right = itemCenter.x + circleRadius,
+ bottom = itemCenter.y + circleRadius
+ )
+ )
+ }
+
+ TargetShape.ROUNDED_RECTANGLE -> {
+ // Draw a rounded rectangle for the inner pulse
+ val rect = Rect(
+ left = itemCenter.x - rectHalfWidth,
+ top = itemCenter.y - rectHalfHeight,
+ right = itemCenter.x + rectHalfWidth,
+ bottom = itemCenter.y + rectHalfHeight
+ )
+ val cornerRadiusPx = cornerRadius.toPx()
+
+ // Top-left arc
+ moveTo(rect.left, rect.top + cornerRadiusPx)
+ arcTo(
+ rect = Rect(
+ left = rect.left,
+ top = rect.top,
+ right = rect.left + cornerRadiusPx * 2,
+ bottom = rect.top + cornerRadiusPx * 2
+ ),
+ startAngleDegrees = 180f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ // Top-right arc
+ lineTo(rect.right - cornerRadiusPx, rect.top)
+ arcTo(
+ rect = Rect(
+ left = rect.right - cornerRadiusPx * 2,
+ top = rect.top,
+ right = rect.right,
+ bottom = rect.top + cornerRadiusPx * 2
+ ),
+ startAngleDegrees = 270f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ // Bottom-right arc
+ lineTo(rect.right, rect.bottom - cornerRadiusPx)
+ arcTo(
+ rect = Rect(
+ left = rect.right - cornerRadiusPx * 2,
+ top = rect.bottom - cornerRadiusPx * 2,
+ right = rect.right,
+ bottom = rect.bottom
+ ),
+ startAngleDegrees = 0f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ // Bottom-left arc
+ lineTo(rect.left + cornerRadiusPx, rect.bottom)
+ arcTo(
+ rect = Rect(
+ left = rect.left,
+ top = rect.bottom - cornerRadiusPx * 2,
+ right = rect.left + cornerRadiusPx * 2,
+ bottom = rect.bottom
+ ),
+ startAngleDegrees = 90f,
+ sweepAngleDegrees = 90f,
+ forceMoveTo = false
+ )
+
+ close()
+ }
+
+ else -> {
+ // Draw a rectangle for the inner pulse that matches the target's exact dimensions
+ addRect(
+ Rect(
+ left = itemCenter.x - rectHalfWidth,
+ top = itemCenter.y - rectHalfHeight,
+ right = itemCenter.x + rectHalfWidth,
+ bottom = itemCenter.y + rectHalfHeight
+ )
+ )
+ }
+ }
+ },
+ operation = PathOperation.Difference
+ )
+ }
+
+ // Apply canvasAlpha to make the pulse animation also disappear during transitions
+ drawPath(
+ path = pulsePath,
+ color = Color.White.copy(alpha = pulseAlpha.value * canvasAlpha.value),
+ style = Fill
+ )
+
+ // Draw the message text between the inner and outer circle bounds
+ message?.let { msg ->
+ // Constants for padding and spacing - use the same values as in the outer circle adjustment
+ // to ensure consistency
+ val safetyPadding = 100f
+ val textPadding = 24f
+
+ // Calculate the maximum width available for text
+ // Use a narrower width to ensure text wraps appropriately and stays within bounds
+ // Use a more conservative width to ensure text doesn't extend beyond outer circle
+ val calculatedWidth =
+ min(size.width.toInt() - 200, (outerRight - outerLeft - 4 * safetyPadding).toInt())
+ // Ensure maxTextWidth is always positive to avoid Constraints exception
+ val maxTextWidth = max(1, calculatedWidth)
+
+ // Measure text with appropriate constraints to ensure it wraps if needed
+ val textResult = textMeasurer.measure(
+ msg.text,
+ style = msg.textStyle,
+ overflow = TextOverflow.Visible,
+ constraints = Constraints(0, maxTextWidth)
+ )
+
+ // Check if displaying the text above the target would make it go outside the screen bounds
+ // Use increased safety padding to ensure more space between text and outer circle
+ // Use rectHalfHeight for rectangle mode to position text correctly relative to the rectangle
+ val verticalOffset = if (targetShape == TargetShape.CIRCLE) circleRadius else rectHalfHeight
+ val textAboveY = itemCenter.y - verticalOffset - textResult.size.height - safetyPadding * 1.5f
+ val textBelowY = itemCenter.y + verticalOffset + safetyPadding * 1.5f
+ val isTextAbove = textAboveY >= safetyPadding
+
+ // If text would be outside the screen bounds when displayed above, show it below
+ val textY = if (!isTextAbove) {
+ scope.showcaseEventListener?.onEvent(
+ Level.INFO,
+ TAG + "Text would be outside screen bounds if displayed above. Displaying below target instead."
+ )
+ // Make sure text stays within the adjusted outer circle when displayed below
+ min(textBelowY, outerBottom - textResult.size.height - safetyPadding * 1.5f)
+ } else {
+ // Make sure text stays within the adjusted outer circle when displayed above
+ max(textAboveY, outerTop + safetyPadding * 1.5f)
+ }
+
+ // Log if text is very close to the outer circle bounds
+ if (isTextAbove && textY < outerTop + safetyPadding * 2) {
+ scope.showcaseEventListener?.onEvent(
+ Level.INFO,
+ TAG + "Text is very close to the top edge of the outer circle."
+ )
+ } else if (!isTextAbove && textY + textResult.size.height > outerBottom - safetyPadding * 2) {
+ scope.showcaseEventListener?.onEvent(
+ Level.INFO,
+ TAG + "Text is very close to the bottom edge of the outer circle."
+ )
+ }
+
+ // Ensure text doesn't extend beyond the left or right edges of the screen and outer circle
+ var textX = itemCenter.x - textResult.size.width / 2
+
+ // Use increased safety padding for all edge checks
+ val horizontalSafetyPadding = safetyPadding * 1.5f
+
+ // Adjust if text would go beyond left edge of screen
+ if (textX < horizontalSafetyPadding) {
+ scope.showcaseEventListener?.onEvent(
+ Level.INFO,
+ TAG + "Text would extend beyond left edge of screen. Adjusting position."
+ )
+ textX = horizontalSafetyPadding
+ }
+
+ // Adjust if text would go beyond right edge of screen
+ if (textX + textResult.size.width > size.width - horizontalSafetyPadding) {
+ scope.showcaseEventListener?.onEvent(
+ Level.INFO,
+ TAG + "Text would extend beyond right edge of screen. Adjusting position."
+ )
+ textX = size.width - textResult.size.width - horizontalSafetyPadding
+ }
+
+ // Also ensure text stays within the horizontal bounds of the outer circle
+ // with increased safety padding
+ if (textX < outerLeft + horizontalSafetyPadding) {
+ scope.showcaseEventListener?.onEvent(
+ Level.INFO,
+ TAG + "Text would extend beyond left edge of outer circle. Adjusting position."
+ )
+ textX = outerLeft + horizontalSafetyPadding
+ }
+
+ if (textX + textResult.size.width > outerRight - horizontalSafetyPadding) {
+ scope.showcaseEventListener?.onEvent(
+ Level.INFO,
+ TAG + "Text would extend beyond right edge of outer circle. Adjusting position."
+ )
+ textX = outerRight - textResult.size.width - horizontalSafetyPadding
+ }
+
+ // If after all adjustments, text still doesn't fit within screen bounds,
+ // prioritize keeping it on screen over keeping it within outer circle
+ if (textX < horizontalSafetyPadding) {
+ textX = horizontalSafetyPadding
+ scope.showcaseEventListener?.onEvent(
+ Level.INFO,
+ TAG + "Text still extends beyond left edge of screen. Forcing on-screen position."
+ )
+ }
+
+ if (textX + textResult.size.width > size.width - horizontalSafetyPadding) {
+ textX = size.width - textResult.size.width - horizontalSafetyPadding
+ scope.showcaseEventListener?.onEvent(
+ Level.INFO,
+ TAG + "Text still extends beyond right edge of screen. Forcing on-screen position."
+ )
+ }
+
+ // Draw a background for the text with increased padding
+ // Use a larger padding for the background to ensure text has room to breathe
+ val bgPadding = textPadding * 1.5f
+ val bgRect = Rect(
+ left = textX - bgPadding,
+ top = textY - bgPadding,
+ right = textX + textResult.size.width + bgPadding,
+ bottom = textY + textResult.size.height + bgPadding
+ )
+
+ // Add an additional safety margin for the check
+ val extraSafetyMargin = safetyPadding * 0.75f
+
+ // Log if text is very close to the outer circle bounds
+ if (bgRect.left < outerLeft + safetyPadding + extraSafetyMargin ||
+ bgRect.right > outerRight - safetyPadding - extraSafetyMargin ||
+ bgRect.top < outerTop + safetyPadding + extraSafetyMargin ||
+ bgRect.bottom > outerBottom - safetyPadding - extraSafetyMargin
+ ) {
+ scope.showcaseEventListener?.onEvent(
+ Level.INFO,
+ TAG + "Text is very close to the edge of the outer circle."
+ )
+ }
+
+ // Draw the text with the animated alpha
+ drawText(
+ textResult,
+ topLeft = Offset(textX, textY),
+ alpha = messageTextAlpha.value * canvasAlpha.value
+ )
+ }
+ }
+ }
+ }
+}
+
+suspend fun handleMessageExitAnimation(
+ message: ShowcaseMsg?,
+ messageTextAlpha: Animatable,
+ animationDuration: Int
+) {
+ when (message?.exitAnim) {
+ is MsgAnimation.FadeInOut -> {
+ // Fade out the message text
+ messageTextAlpha.animateTo(
+ 0f,
+ animationSpec = tween(
+ durationMillis = animationDuration / 3,
+ easing = FastOutSlowInEasing
+ )
+ )
+ }
+
+ MsgAnimation.None -> {
+ // Make the message text disappear
+ messageTextAlpha.snapTo(0f)
+ }
+
+ null -> Unit
+ }
+}
+
+suspend fun handleMessageEnterAnimation(
+ message: ShowcaseMsg?,
+ messageTextAlpha: Animatable,
+ animationDuration: Int
+) {
+ when (message?.enterAnim) {
+ is MsgAnimation.FadeInOut -> {
+ // Fade out the message text
+ messageTextAlpha.animateTo(
+ 1f,
+ animationSpec = tween(
+ durationMillis = animationDuration / 3,
+ easing = FastOutSlowInEasing
+ )
+ )
+ }
+
+ MsgAnimation.None -> {
+ // Make the message text disappear
+ messageTextAlpha.snapTo(1f)
+ }
+
+ null -> Unit
+ }
+}