├── app
├── .gitignore
├── src
│ ├── androidMain
│ │ ├── res
│ │ │ ├── values
│ │ │ │ └── strings.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ │ └── drawable
│ │ │ │ └── ic_launcher_background.xml
│ │ ├── kotlin
│ │ │ └── dev
│ │ │ │ └── muazkadan
│ │ │ │ └── switchycomposedemo
│ │ │ │ └── MainActivity.kt
│ │ └── AndroidManifest.xml
│ ├── wasmJsMain
│ │ ├── resources
│ │ │ ├── styles.css
│ │ │ └── index.html
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── muazkadan
│ │ │ └── switchycomposedemo
│ │ │ └── main.kt
│ ├── iosMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── muazkadan
│ │ │ └── switchycomposedemo
│ │ │ └── MainViewController.kt
│ ├── desktopMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── muazkadan
│ │ │ └── switchycomposedemo
│ │ │ └── main.kt
│ ├── jsMain
│ │ ├── kotlin
│ │ │ └── dev
│ │ │ │ └── muazkadan
│ │ │ │ └── switchycomposedemo
│ │ │ │ └── main.kt
│ │ └── resources
│ │ │ └── index.html
│ ├── macosMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── muazkadan
│ │ │ └── switchycomposedemo
│ │ │ └── main.kt
│ └── commonMain
│ │ └── kotlin
│ │ └── dev
│ │ └── muazkadan
│ │ └── switchycomposedemo
│ │ └── PhoneFrame.kt
├── iosApp
│ ├── iosApp
│ │ ├── Assets.xcassets
│ │ │ ├── Contents.json
│ │ │ ├── AppIcon.appiconset
│ │ │ │ ├── app-icon-1024.png
│ │ │ │ └── Contents.json
│ │ │ └── AccentColor.colorset
│ │ │ │ └── Contents.json
│ │ ├── Preview Content
│ │ │ └── Preview Assets.xcassets
│ │ │ │ └── Contents.json
│ │ ├── iOSApp.swift
│ │ ├── Info.plist
│ │ └── ContentView.swift
│ ├── iosApp.xcodeproj
│ │ └── project.xcworkspace
│ │ │ └── contents.xcworkspacedata
│ └── Configuration
│ │ └── Config.xcconfig
└── build.gradle.kts
├── switchycompose
├── .gitignore
└── multiplatform
│ ├── src
│ ├── macosMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── muazkadan
│ │ │ └── switchycompose
│ │ │ └── NativeSwitch.macos.kt
│ ├── jsMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── muazkadan
│ │ │ └── switchycompose
│ │ │ └── NativeSwitch.js.kt
│ ├── androidMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── muazkadan
│ │ │ └── switchycompose
│ │ │ └── NativeSwitch.android.kt
│ ├── wasmJsMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── muazkadan
│ │ │ └── switchycompose
│ │ │ └── NativeSwitch.wasmJs.kt
│ ├── commonMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── muazkadan
│ │ │ └── switchycompose
│ │ │ ├── NativeSwitch.kt
│ │ │ ├── ISwitch.kt
│ │ │ ├── ColoredSwitch.kt
│ │ │ ├── SquareSwitch.kt
│ │ │ ├── IconISwitch.kt
│ │ │ ├── CustomISwitch.kt
│ │ │ ├── TextSwitch.kt
│ │ │ └── MorphingSwitch.kt
│ ├── jvmMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── muazkadan
│ │ │ └── switchycompose
│ │ │ └── NativeSwitch.jvm.kt
│ ├── iosMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── muazkadan
│ │ │ └── switchycompose
│ │ │ └── NativeSwitch.ios.kt
│ └── commonTest
│ │ └── kotlin
│ │ └── dev
│ │ └── muazkadan
│ │ └── switchycompose
│ │ ├── ColoredSwitchTest.kt
│ │ ├── MorphingSwitchTest.kt
│ │ ├── ISwitchTest.kt
│ │ ├── SquareSwitchTest.kt
│ │ ├── NativeSwitchTest.kt
│ │ ├── HeartSwitchTest.kt
│ │ ├── TextSwitchTest.kt
│ │ ├── IconISwitchTest.kt
│ │ ├── CustomISwitchTest.kt
│ │ └── CustomSwitchTest.kt
│ └── build.gradle.kts
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── preview
└── switchy_compose_preview.png
├── jitpack.yml
├── .idea
├── kotlinc.xml
└── .gitignore
├── .devcontainer
└── devcontainer.json
├── settings.gradle
├── .github
├── workflows
│ └── junie.yml
└── dependabot.yml
├── gradle.properties
├── gradlew.bat
├── .gitignore
├── gradlew
└── README.md
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/switchycompose/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muazkadan/switchy-compose/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/androidMain/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | SwitchyComposeDemo
3 |
--------------------------------------------------------------------------------
/preview/switchy_compose_preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muazkadan/switchy-compose/HEAD/preview/switchy_compose_preview.png
--------------------------------------------------------------------------------
/app/iosApp/iosApp/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/app/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/jitpack.yml:
--------------------------------------------------------------------------------
1 | jdk:
2 | - openjdk17
3 | before_install:
4 | - ./gradlew :switchycompose:multiplatform:clean :switchycompose:multiplatform:publishToMavenLocal
--------------------------------------------------------------------------------
/app/src/androidMain/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muazkadan/switchy-compose/HEAD/app/src/androidMain/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/androidMain/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muazkadan/switchy-compose/HEAD/app/src/androidMain/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/androidMain/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muazkadan/switchy-compose/HEAD/app/src/androidMain/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muazkadan/switchy-compose/HEAD/app/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/wasmJsMain/resources/styles.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | width: 100%;
3 | height: 100%;
4 | margin: 0;
5 | padding: 0;
6 | overflow: hidden;
7 | }
--------------------------------------------------------------------------------
/app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muazkadan/switchy-compose/HEAD/app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muazkadan/switchy-compose/HEAD/app/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muazkadan/switchy-compose/HEAD/app/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muazkadan/switchy-compose/HEAD/app/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muazkadan/switchy-compose/HEAD/app/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muazkadan/switchy-compose/HEAD/app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muazkadan/switchy-compose/HEAD/app/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/iosApp/iosApp/iOSApp.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct iOSApp: App {
5 | var body: some Scene {
6 | WindowGroup {
7 | ContentView()
8 | }
9 | }
10 | }
--------------------------------------------------------------------------------
/app/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/app/iosApp/Configuration/Config.xcconfig:
--------------------------------------------------------------------------------
1 | TEAM_ID=
2 |
3 | PRODUCT_NAME=SwitchyComposeDemo
4 | PRODUCT_BUNDLE_IDENTIFIER=dev.muazkadan.switchycomposedemo.SwitchyComposeDemo$(TEAM_ID)
5 |
6 | CURRENT_PROJECT_VERSION=1
7 | MARKETING_VERSION=1.0
--------------------------------------------------------------------------------
/app/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/iosMain/kotlin/dev/muazkadan/switchycomposedemo/MainViewController.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycomposedemo
2 |
3 | import androidx.compose.ui.window.ComposeUIViewController
4 |
5 | fun MainViewController() = ComposeUIViewController { App() }
--------------------------------------------------------------------------------
/app/iosApp/iosApp/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CADisableMinimumFrameDurationOnPhone
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # GitHub Copilot persisted chat sessions
5 | /copilot/chatSessions
6 | /AndroidProjectSystem.xml
7 | /appInsightsSettings.xml
8 | /deploymentTargetSelector.xml
9 | /migrations.xml
10 | /runConfigurations.xml
11 | /studiobot.xml
12 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Java",
3 | "image": "mcr.microsoft.com/devcontainers/java:1-21",
4 | "features": {
5 | "ghcr.io/devcontainers/features/java:1": {
6 | "version": "none",
7 | "installMaven": "true",
8 | "mavenVersion": "3.8.6",
9 | "installGradle": "true"
10 | }
11 | }
12 | }
--------------------------------------------------------------------------------
/app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/desktopMain/kotlin/dev/muazkadan/switchycomposedemo/main.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycomposedemo
2 |
3 | import androidx.compose.ui.window.Window
4 | import androidx.compose.ui.window.application
5 |
6 | fun main() = application {
7 | Window(
8 | onCloseRequest = ::exitApplication,
9 | title = "SwitchyComposeDemo",
10 | ) {
11 | App()
12 | }
13 | }
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositories {
10 | google()
11 | mavenCentral()
12 | }
13 | }
14 | rootProject.name = "switchycomposedemo"
15 | include ':app'
16 | include ':switchycompose:multiplatform'
17 |
--------------------------------------------------------------------------------
/app/src/wasmJsMain/resources/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | SwitchyComposeDemo
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/jsMain/kotlin/dev/muazkadan/switchycomposedemo/main.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycomposedemo
2 |
3 | import androidx.compose.ui.ExperimentalComposeUiApi
4 | import androidx.compose.ui.window.ComposeViewport
5 | import org.jetbrains.skiko.wasm.onWasmReady
6 | import kotlinx.browser.document
7 |
8 | @OptIn(ExperimentalComposeUiApi::class)
9 | fun main() {
10 | onWasmReady {
11 | val body = document.body ?: return@onWasmReady
12 | ComposeViewport(body) {
13 | App()
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/macosMain/kotlin/dev/muazkadan/switchycompose/NativeSwitch.macos.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.material3.Switch
4 |
5 | @androidx.compose.runtime.Composable
6 | actual fun NativeSwitch(
7 | checked: Boolean,
8 | onCheckedChange: ((Boolean) -> Unit)?,
9 | modifier: androidx.compose.ui.Modifier,
10 | enabled: Boolean
11 | ) {
12 | Switch(
13 | checked = checked,
14 | onCheckedChange = onCheckedChange,
15 | modifier = modifier,
16 | enabled = enabled
17 | )
18 | }
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/jsMain/kotlin/dev/muazkadan/switchycompose/NativeSwitch.js.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.material3.Switch
4 | import androidx.compose.runtime.Composable
5 |
6 | @Composable
7 | actual fun NativeSwitch(
8 | checked: Boolean,
9 | onCheckedChange: ((Boolean) -> Unit)?,
10 | modifier: androidx.compose.ui.Modifier,
11 | enabled: Boolean
12 | ) {
13 | Switch(
14 | checked = checked,
15 | onCheckedChange = onCheckedChange,
16 | modifier = modifier,
17 | enabled = enabled
18 | )
19 | }
--------------------------------------------------------------------------------
/app/iosApp/iosApp/ContentView.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import SwiftUI
3 | import ComposeApp
4 |
5 | struct ComposeView: UIViewControllerRepresentable {
6 | func makeUIViewController(context: Context) -> UIViewController {
7 | MainViewControllerKt.MainViewController()
8 | }
9 |
10 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
11 | }
12 |
13 | struct ContentView: View {
14 | var body: some View {
15 | ComposeView()
16 | .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
17 | }
18 | }
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.github/workflows/junie.yml:
--------------------------------------------------------------------------------
1 | name: Junie
2 | run-name: Junie run ${{ inputs.run_id }}
3 |
4 | permissions:
5 | contents: write
6 | pull-requests: write
7 |
8 | on:
9 | workflow_dispatch:
10 | inputs:
11 | run_id:
12 | description: "id of workflow process"
13 | required: true
14 | workflow_params:
15 | description: "stringified params"
16 | required: true
17 |
18 | jobs:
19 | call-workflow-passing-data:
20 | uses: jetbrains-junie/junie-workflows/.github/workflows/ej-issue.yml@main
21 | with:
22 | workflow_params: ${{ inputs.workflow_params }}
23 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "gradle" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "daily"
12 | rebase-strategy: "disabled"
13 |
14 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/androidMain/kotlin/dev/muazkadan/switchycompose/NativeSwitch.android.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.material3.Switch
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.Modifier
6 |
7 | @Composable
8 | actual fun NativeSwitch(
9 | checked: Boolean,
10 | onCheckedChange: ((Boolean) -> Unit)?,
11 | modifier: Modifier,
12 | enabled: Boolean
13 | ) {
14 | Switch(
15 | checked = checked,
16 | onCheckedChange = onCheckedChange,
17 | modifier = modifier,
18 | enabled = enabled
19 | )
20 | }
--------------------------------------------------------------------------------
/app/src/jsMain/resources/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | SwitchyComposeDemo
7 |
8 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/wasmJsMain/kotlin/dev/muazkadan/switchycompose/NativeSwitch.wasmJs.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.material3.Switch
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.Modifier
6 |
7 | @Composable
8 | actual fun NativeSwitch(
9 | checked: Boolean,
10 | onCheckedChange: ((Boolean) -> Unit)?,
11 | modifier: Modifier,
12 | enabled: Boolean
13 | ) {
14 | // For Wasm/JS, we use Material3's Switch component which works well in web contexts
15 | Switch(
16 | checked = checked,
17 | onCheckedChange = onCheckedChange,
18 | modifier = modifier,
19 | enabled = enabled
20 | )
21 | }
--------------------------------------------------------------------------------
/app/src/androidMain/kotlin/dev/muazkadan/switchycomposedemo/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycomposedemo
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.tooling.preview.Preview
9 |
10 | class MainActivity : ComponentActivity() {
11 | override fun onCreate(savedInstanceState: Bundle?) {
12 | enableEdgeToEdge()
13 | super.onCreate(savedInstanceState)
14 |
15 | setContent {
16 | App()
17 | }
18 | }
19 | }
20 |
21 | @Preview
22 | @Composable
23 | fun AppAndroidPreview() {
24 | App()
25 | }
--------------------------------------------------------------------------------
/app/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "app-icon-1024.png",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | },
9 | {
10 | "appearances" : [
11 | {
12 | "appearance" : "luminosity",
13 | "value" : "dark"
14 | }
15 | ],
16 | "idiom" : "universal",
17 | "platform" : "ios",
18 | "size" : "1024x1024"
19 | },
20 | {
21 | "appearances" : [
22 | {
23 | "appearance" : "luminosity",
24 | "value" : "tinted"
25 | }
26 | ],
27 | "idiom" : "universal",
28 | "platform" : "ios",
29 | "size" : "1024x1024"
30 | }
31 | ],
32 | "info" : {
33 | "author" : "xcode",
34 | "version" : 1
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/macosMain/kotlin/dev/muazkadan/switchycomposedemo/main.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycomposedemo
2 |
3 | import androidx.compose.ui.window.Window
4 | import platform.AppKit.NSApplication
5 | import platform.AppKit.NSApplicationActivationPolicy
6 | import platform.AppKit.NSApplicationDelegateProtocol
7 | import platform.darwin.NSObject
8 |
9 | fun main() {
10 | val nsApplication = NSApplication.sharedApplication()
11 | nsApplication.setActivationPolicy(NSApplicationActivationPolicy.NSApplicationActivationPolicyRegular)
12 | nsApplication.delegate = object : NSObject(), NSApplicationDelegateProtocol {
13 | override fun applicationShouldTerminateAfterLastWindowClosed(sender: NSApplication): Boolean {
14 | return true
15 | }
16 | }
17 | Window("Compose macOS demo") {
18 | App()
19 | }
20 | nsApplication.run()
21 | }
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonMain/kotlin/dev/muazkadan/switchycompose/NativeSwitch.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.runtime.Composable
4 |
5 | /**
6 | * A composable function that provides a platform-specific native switch implementation.
7 | * On Android, it uses Material3's Switch component, while on iOS, it uses UIKit's UISwitch.
8 | *
9 | * @param checked The current checked state of the switch.
10 | * @param onCheckedChange Callback invoked when the switch state changes. If null, the switch will be non-interactive.
11 | * @param modifier The modifier to be applied to the switch.
12 | * @param enabled Whether the switch is enabled and can be interacted with. Default is true.
13 | */
14 | @Composable
15 | expect fun NativeSwitch(
16 | checked: Boolean,
17 | onCheckedChange: ((Boolean) -> Unit)? = null,
18 | modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier,
19 | enabled: Boolean = true,
20 | )
--------------------------------------------------------------------------------
/app/src/androidMain/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/jvmMain/kotlin/dev/muazkadan/switchycompose/NativeSwitch.jvm.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.foundation.layout.size
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.remember
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.awt.SwingPanel
8 | import androidx.compose.ui.unit.dp
9 | import java.awt.event.ItemEvent
10 | import javax.swing.JToggleButton
11 |
12 | @Composable
13 | actual fun NativeSwitch(
14 | checked: Boolean,
15 | onCheckedChange: ((Boolean) -> Unit)?,
16 | modifier: Modifier,
17 | enabled: Boolean,
18 | ) {
19 | val rememberedListener = remember(onCheckedChange) { onCheckedChange }
20 |
21 | SwingPanel(
22 | factory = {
23 | JToggleButton().apply {
24 | isSelected = checked
25 | isEnabled = enabled
26 | addItemListener { event ->
27 | if (event.stateChange == ItemEvent.SELECTED || event.stateChange == ItemEvent.DESELECTED) {
28 | rememberedListener?.invoke(isSelected)
29 | }
30 | }
31 | }
32 | },
33 | update = { button ->
34 | if (button.isSelected != checked) {
35 | button.isSelected = checked
36 | }
37 | button.isEnabled = enabled
38 | },
39 | modifier = modifier.size(51.dp, 31.dp)
40 | )
41 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
24 |
25 | org.jetbrains.compose.experimental.macos.enabled=true
26 | org.jetbrains.compose.experimental.jscanvas.enabled=true
--------------------------------------------------------------------------------
/app/src/wasmJsMain/kotlin/dev/muazkadan/switchycomposedemo/main.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycomposedemo
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.BoxWithConstraints
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.ui.Alignment
8 | import androidx.compose.ui.ExperimentalComposeUiApi
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.unit.dp
11 | import androidx.compose.ui.window.ComposeViewport
12 | import kotlinx.browser.document
13 |
14 | @OptIn(ExperimentalComposeUiApi::class)
15 | fun main() {
16 | ComposeViewport(document.body!!) {
17 | BoxWithConstraints(
18 | modifier = Modifier.fillMaxSize(),
19 | contentAlignment = Alignment.Center
20 | ) {
21 | // Hide PhoneFrame on smaller screens (phones)
22 | // Show PhoneFrame only on larger screens (tablets/desktop)
23 | if (maxWidth >= 410.dp && maxHeight >= 791.dp) {
24 | // Large screen - show PhoneFrame with App inside
25 | Box(
26 | modifier = Modifier.fillMaxSize().padding(16.dp),
27 | contentAlignment = Alignment.Center
28 | ) {
29 | PhoneFrame {
30 | App()
31 | }
32 | }
33 | } else {
34 | // Small screen (phone) - show App directly without PhoneFrame
35 | App()
36 | }
37 | }
38 | }
39 | }
--------------------------------------------------------------------------------
/app/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.12.0"
3 | kotlin = "2.2.0"
4 | androidx-core-ktx = "1.16.0"
5 | compose-bom = "2025.07.00"
6 | compose-activity = "1.10.1"
7 | android-minSdk = "21"
8 | android-compileSdk = "35"
9 | compose-multiplatform = "1.8.2"
10 | composeHotReload = "1.0.0-beta04"
11 | androidx-lifecycle = "2.9.1"
12 | kotlinx-coroutines = "1.10.2"
13 |
14 | [libraries]
15 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
16 | kotlin-bom = { group = "org.jetbrains.kotlin", name = "kotlin-bom", version.ref = "kotlin" }
17 | compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
18 | compose-ui = { group = "androidx.compose.ui", name = "ui" }
19 | compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
20 | compose-material = { group = "androidx.compose.material3", name = "material3" }
21 | compose-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
22 | compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "compose-activity" }
23 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
24 | androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
25 | androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
26 | kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
27 |
28 | [plugins]
29 | com-android-application = { id = "com.android.application", version.ref = "agp" }
30 | com-android-library = { id = "com.android.library", version.ref = "agp" }
31 | org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
32 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
33 | kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
34 | composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
35 | composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" }
36 | vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version = "0.34.0" }
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%"=="" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%"=="" set DIRNAME=.
29 | @rem This is normally unused
30 | set APP_BASE_NAME=%~n0
31 | set APP_HOME=%DIRNAME%
32 |
33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
35 |
36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
38 |
39 | @rem Find java.exe
40 | if defined JAVA_HOME goto findJavaFromJavaHome
41 |
42 | set JAVA_EXE=java.exe
43 | %JAVA_EXE% -version >NUL 2>&1
44 | if %ERRORLEVEL% equ 0 goto execute
45 |
46 | echo.
47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
48 | echo.
49 | echo Please set the JAVA_HOME variable in your environment to match the
50 | echo location of your Java installation.
51 |
52 | goto fail
53 |
54 | :findJavaFromJavaHome
55 | set JAVA_HOME=%JAVA_HOME:"=%
56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
57 |
58 | if exist "%JAVA_EXE%" goto execute
59 |
60 | echo.
61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
62 | echo.
63 | echo Please set the JAVA_HOME variable in your environment to match the
64 | echo location of your Java installation.
65 |
66 | goto fail
67 |
68 | :execute
69 | @rem Setup the command line
70 |
71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
72 |
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if %ERRORLEVEL% equ 0 goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | set EXIT_CODE=%ERRORLEVEL%
85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
87 | exit /b %EXIT_CODE%
88 |
89 | :mainEnd
90 | if "%OS%"=="Windows_NT" endlocal
91 |
92 | :omega
93 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/iosMain/kotlin/dev/muazkadan/switchycompose/NativeSwitch.ios.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.foundation.layout.size
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.remember
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.unit.dp
8 | import androidx.compose.ui.viewinterop.UIKitInteropProperties
9 | import androidx.compose.ui.viewinterop.UIKitView
10 | import kotlinx.cinterop.BetaInteropApi
11 | import kotlinx.cinterop.ExperimentalForeignApi
12 | import kotlinx.cinterop.ObjCAction
13 | import platform.UIKit.UISwitch
14 | import platform.UIKit.UIControlEventValueChanged
15 | import platform.darwin.NSObject
16 |
17 | private class SwitchTarget(
18 | private val onCheckedChange: ((Boolean) -> Unit)?
19 | ) : NSObject() {
20 | @ObjCAction
21 | fun switchValueChanged(sender: UISwitch) {
22 | onCheckedChange?.invoke(sender.isOn())
23 | }
24 | }
25 |
26 | @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
27 | @Composable
28 | actual fun NativeSwitch(
29 | checked: Boolean,
30 | onCheckedChange: ((Boolean) -> Unit)?,
31 | modifier: Modifier,
32 | enabled: Boolean
33 | ) {
34 | // Remember the target to ensure it's retained properly
35 | val target = remember(onCheckedChange) {
36 | SwitchTarget(onCheckedChange)
37 | }
38 |
39 | UIKitView(
40 | factory = {
41 | val switch = UISwitch()
42 |
43 | // Set initial state
44 | switch.setOn(checked, animated = false)
45 | switch.setEnabled(enabled)
46 |
47 | // Size the switch to fit its content
48 | switch.sizeToFit()
49 |
50 | // Add target-action for value changes
51 | onCheckedChange?.let {
52 | switch.addTarget(
53 | target = target,
54 | action = platform.objc.sel_registerName("switchValueChanged:"),
55 | forControlEvents = UIControlEventValueChanged
56 | )
57 | }
58 |
59 | switch
60 | },
61 | modifier = modifier
62 | // Provide default intrinsic size for UISwitch (51x31 points)
63 | .size(width = 51.dp, height = 31.dp),
64 | update = { view ->
65 | val switch = view
66 |
67 | // Update switch state if it differs from current state
68 | if (switch.isOn() != checked) {
69 | switch.setOn(checked, animated = true)
70 | }
71 |
72 | // Update enabled state
73 | switch.setEnabled(enabled)
74 |
75 | // Update target-action when onCheckedChange changes
76 | switch.removeTarget(target, action = null, forControlEvents = UIControlEventValueChanged)
77 | onCheckedChange?.let {
78 | switch.addTarget(
79 | target = target,
80 | action = platform.objc.sel_registerName("switchValueChanged:"),
81 | forControlEvents = UIControlEventValueChanged
82 | )
83 | }
84 | },
85 | properties = UIKitInteropProperties(
86 | isInteractive = true,
87 | isNativeAccessibilityEnabled = true
88 | )
89 | )
90 | }
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/dev/muazkadan/switchycomposedemo/PhoneFrame.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycomposedemo
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.foundation.shape.RoundedCornerShape
5 | import androidx.compose.material3.MaterialTheme
6 | import androidx.compose.material3.Surface
7 | import androidx.compose.material3.Text
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Alignment
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.graphics.Color
12 | import androidx.compose.ui.unit.dp
13 | import org.jetbrains.compose.ui.tooling.preview.Preview
14 |
15 | @Composable
16 | fun PhoneFrame(
17 | modifier: Modifier = Modifier,
18 | phoneColor: Color = Color.Black,
19 | screenColor: Color = MaterialTheme.colorScheme.surface,
20 | content: @Composable ColumnScope.() -> Unit
21 | ) {
22 | Box(
23 | modifier = modifier,
24 | contentAlignment = Alignment.Center
25 | ) {
26 | // Phone outer frame with aspect ratio
27 | Surface(
28 | modifier = Modifier
29 | .aspectRatio(0.5f) // Phone aspect ratio (width:height = 1:2)
30 | .fillMaxHeight(0.8f), // Limit max height to 80% of available space
31 | shape = RoundedCornerShape(32.dp),
32 | color = phoneColor,
33 | shadowElevation = 8.dp
34 | ) {
35 | Column(
36 | modifier = Modifier
37 | .fillMaxSize()
38 | .padding(16.dp)
39 | ) {
40 | // Top speaker/notch area
41 | Box(
42 | modifier = Modifier
43 | .fillMaxWidth()
44 | .height(24.dp),
45 | contentAlignment = Alignment.Center
46 | ) {
47 | Surface(
48 | modifier = Modifier
49 | .width(120.dp)
50 | .height(20.dp),
51 | shape = RoundedCornerShape(10.dp),
52 | color = Color.Black.copy(alpha = 0.3f)
53 | ) {}
54 | }
55 |
56 | Spacer(modifier = Modifier.height(8.dp))
57 |
58 | // Screen area - this is where your content goes
59 | Surface(
60 | modifier = Modifier
61 | .fillMaxWidth()
62 | .weight(1f),
63 | shape = RoundedCornerShape(16.dp),
64 | color = screenColor
65 | ) {
66 | Column(
67 | modifier = Modifier
68 | .fillMaxSize()
69 | ) {
70 | content()
71 | }
72 | }
73 |
74 | Spacer(modifier = Modifier.height(8.dp))
75 |
76 | // Bottom home indicator
77 | Box(
78 | modifier = Modifier
79 | .fillMaxWidth()
80 | .height(24.dp),
81 | contentAlignment = Alignment.Center
82 | ) {
83 | Surface(
84 | modifier = Modifier
85 | .width(134.dp)
86 | .height(4.dp),
87 | shape = RoundedCornerShape(2.dp),
88 | color = Color.White.copy(alpha = 0.3f)
89 | ) {}
90 | }
91 | }
92 | }
93 | }
94 | }
95 |
96 | @Preview
97 | @Composable
98 | private fun MobilePhoneFramePreview() {
99 | MaterialTheme {
100 | PhoneFrame {
101 | App()
102 | }
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonTest/kotlin/dev/muazkadan/switchycompose/ColoredSwitchTest.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.remember
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.platform.testTag
8 | import androidx.compose.ui.test.ExperimentalTestApi
9 | import androidx.compose.ui.test.assertIsDisplayed
10 | import androidx.compose.ui.test.assertIsOff
11 | import androidx.compose.ui.test.assertIsOn
12 | import androidx.compose.ui.test.assertIsNotEnabled
13 | import androidx.compose.ui.test.onNodeWithTag
14 | import androidx.compose.ui.test.performClick
15 | import androidx.compose.ui.test.runComposeUiTest
16 | import kotlin.test.Test
17 | import kotlin.test.assertEquals
18 |
19 | @OptIn(ExperimentalTestApi::class)
20 | class ColoredSwitchTest {
21 |
22 | @Test
23 | fun testInitialStateUnchecked() = runComposeUiTest {
24 | setContent {
25 | MaterialTheme {
26 | ColoredSwitch(
27 | checked = false,
28 | onCheckedChange = {},
29 | modifier = Modifier.testTag("switchUnchecked"),
30 | )
31 | }
32 | }
33 | onNodeWithTag("switchUnchecked").assertIsDisplayed().assertIsOff()
34 | }
35 |
36 | @Test
37 | fun testInitialStateChecked() = runComposeUiTest {
38 | setContent {
39 | MaterialTheme {
40 | ColoredSwitch(
41 | checked = true,
42 | onCheckedChange = {},
43 | modifier = Modifier.testTag("switchChecked")
44 | )
45 | }
46 | }
47 | onNodeWithTag("switchChecked").assertIsDisplayed().assertIsOn()
48 | }
49 |
50 | @Test
51 | fun testStateChangeOnClick() = runComposeUiTest {
52 | setContent {
53 | MaterialTheme {
54 | val isChecked = remember { mutableStateOf(false) }
55 | ColoredSwitch(
56 | checked = isChecked.value,
57 | onCheckedChange = { isChecked.value = it },
58 | modifier = Modifier.testTag("switch")
59 | )
60 | }
61 | }
62 | onNodeWithTag("switch").assertIsOff().performClick().assertIsOn()
63 | }
64 |
65 | @Test
66 | fun testOnCheckedChangeCallback() = runComposeUiTest {
67 | var callbackValue = false
68 | setContent {
69 | MaterialTheme {
70 | ColoredSwitch(
71 | checked = false,
72 | onCheckedChange = { callbackValue = it },
73 | modifier = Modifier.testTag("switch")
74 | )
75 | }
76 | }
77 | onNodeWithTag("switch").performClick()
78 | assertEquals(true, callbackValue)
79 | }
80 |
81 | @Test
82 | fun testDisabledStateUnchecked() = runComposeUiTest {
83 | setContent {
84 | MaterialTheme {
85 | ColoredSwitch(
86 | checked = false,
87 | onCheckedChange = {},
88 | enabled = false,
89 | modifier = Modifier.testTag("switchDisabledUnchecked")
90 | )
91 | }
92 | }
93 | onNodeWithTag("switchDisabledUnchecked").assertIsNotEnabled().assertIsOff()
94 | }
95 |
96 | @Test
97 | fun testDisabledStateChecked() = runComposeUiTest {
98 | setContent {
99 | MaterialTheme {
100 | ColoredSwitch(
101 | checked = true,
102 | onCheckedChange = {},
103 | enabled = false,
104 | modifier = Modifier.testTag("switchDisabledChecked")
105 | )
106 | }
107 | }
108 | onNodeWithTag("switchDisabledChecked").assertIsNotEnabled().assertIsOn()
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/build.gradle.kts:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalWasmDsl::class, ExperimentalComposeLibrary::class)
2 |
3 | import org.jetbrains.compose.ExperimentalComposeLibrary
4 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
5 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
6 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
7 |
8 | plugins {
9 | alias(libs.plugins.kotlinMultiplatform)
10 | alias(libs.plugins.com.android.library)
11 | alias(libs.plugins.composeMultiplatform)
12 | alias(libs.plugins.compose.compiler)
13 | alias(libs.plugins.vanniktech.mavenPublish)
14 | }
15 |
16 | group = "dev.muazkadan"
17 | version = "0.7.0"
18 |
19 | kotlin {
20 | jvm()
21 | androidTarget {
22 | publishLibraryVariants("release")
23 | @OptIn(ExperimentalKotlinGradlePluginApi::class)
24 | compilerOptions {
25 | jvmTarget.set(JvmTarget.JVM_11)
26 | }
27 | }
28 |
29 | js { browser() }
30 |
31 | wasmJs { browser() }
32 |
33 | listOf(
34 | iosX64(),
35 | iosArm64(),
36 | iosSimulatorArm64()
37 | ).forEach {
38 | it.binaries.framework {
39 | baseName = "SwitchyCompose"
40 | isStatic = true
41 | }
42 | }
43 |
44 | // macOS targets
45 | listOf(
46 | macosX64(),
47 | macosArm64()
48 | ).forEach {
49 | it.binaries.framework {
50 | baseName = "SwitchyCompose"
51 | isStatic = true
52 | }
53 | }
54 |
55 | sourceSets {
56 | androidMain.dependencies {
57 | implementation(compose.preview)
58 | }
59 |
60 | val commonMain by getting {
61 | dependencies {
62 | implementation(compose.runtime)
63 | implementation(compose.foundation)
64 | implementation(compose.material3)
65 | implementation(compose.ui)
66 | implementation(compose.components.uiToolingPreview)
67 | implementation(compose.materialIconsExtended)
68 | }
69 | }
70 | val commonTest by getting {
71 | dependencies {
72 | implementation(libs.kotlin.test)
73 | implementation(compose.uiTest)
74 | }
75 | }
76 |
77 | jvmMain.dependencies {
78 | implementation(compose.desktop.currentOs)
79 | }
80 | }
81 | }
82 |
83 | android {
84 | namespace = "dev.muazkadan.switchycompose"
85 | compileSdk = libs.versions.android.compileSdk.get().toInt()
86 | defaultConfig {
87 | minSdk = libs.versions.android.minSdk.get().toInt()
88 | }
89 | compileOptions {
90 | sourceCompatibility = JavaVersion.VERSION_11
91 | targetCompatibility = JavaVersion.VERSION_11
92 | }
93 | }
94 |
95 | mavenPublishing {
96 | publishToMavenCentral()
97 |
98 | // Only sign if signing properties are available (e.g., for Maven Central)
99 | // This prevents signing issues when building on JitPack
100 | if (project.hasProperty("signing.keyId") &&
101 | project.hasProperty("signing.password") &&
102 | project.hasProperty("signing.secretKeyRingFile")
103 | ) {
104 | signAllPublications()
105 | }
106 |
107 | coordinates(group.toString(), "switchy-compose", version.toString())
108 |
109 | pom {
110 | name = "Switchy Compose"
111 | description =
112 | "A modern, customizable switch component library for Kotlin Multiplatform that provides beautiful animated switches with various styles and configurations."
113 | inceptionYear = "2025"
114 | url = "https://github.com/muazkadan/switchy-compose"
115 | licenses {
116 | license {
117 | name = "The Apache License, Version 2.0"
118 | url = "http://www.apache.org/licenses/LICENSE-2.0.txt"
119 | }
120 | }
121 | developers {
122 | developer {
123 | id = "muazkadan"
124 | name = "Muaz KADAN"
125 | url = "https://muazkadan.dev/"
126 | }
127 | }
128 | scm {
129 | url = "https://github.com/muazkadan/switchy-compose"
130 | connection = "scm:git:git://github.com/muazkadan/switchy-compose.git"
131 | developerConnection = "scm:git:ssh://github.com/muazkadan/switchy-compose.git"
132 | }
133 | }
134 | }
135 |
136 | dependencies {
137 | debugImplementation(compose.uiTooling)
138 | }
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalWasmDsl::class)
2 |
3 | import org.gradle.kotlin.dsl.assign
4 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat
5 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
6 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
7 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
8 | import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
9 |
10 | plugins {
11 | alias(libs.plugins.kotlinMultiplatform)
12 | alias(libs.plugins.com.android.application)
13 | alias(libs.plugins.composeMultiplatform)
14 | alias(libs.plugins.compose.compiler)
15 | alias(libs.plugins.composeHotReload)
16 | }
17 |
18 | kotlin {
19 | androidTarget {
20 | @OptIn(ExperimentalKotlinGradlePluginApi::class)
21 | compilerOptions {
22 | jvmTarget.set(JvmTarget.JVM_11)
23 | }
24 | }
25 |
26 | listOf(
27 | iosX64(),
28 | iosArm64(),
29 | iosSimulatorArm64()
30 | ).forEach { iosTarget ->
31 | iosTarget.binaries.framework {
32 | baseName = "ComposeApp"
33 | isStatic = true
34 | }
35 | }
36 |
37 | // macOS targets
38 | listOf(
39 | macosX64(),
40 | macosArm64()
41 | ).forEach { macosTarget ->
42 | macosTarget.binaries.framework {
43 | baseName = "ComposeApp"
44 | isStatic = true
45 | }
46 | macosTarget.binaries.executable {
47 | entryPoint = "dev.muazkadan.switchycomposedemo.main"
48 | }
49 | }
50 |
51 | jvm("desktop")
52 |
53 | listOf(js(), wasmJs()).forEach { target ->
54 | target.outputModuleName = "composeApp"
55 | target.browser {
56 | val rootDirPath = project.rootDir.path
57 | val projectDirPath = project.projectDir.path
58 | commonWebpackConfig {
59 | outputFileName = "composeApp.js"
60 | devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply {
61 | static = (static ?: mutableListOf()).apply {
62 | // Serve sources to debug inside browser
63 | add(rootDirPath)
64 | add(projectDirPath)
65 | }
66 | }
67 | }
68 | }
69 | target.binaries.executable()
70 |
71 | }
72 |
73 | sourceSets {
74 | val desktopMain by getting
75 |
76 | androidMain.dependencies {
77 | implementation(compose.preview)
78 | implementation(libs.compose.activity)
79 | }
80 | commonMain.dependencies {
81 | implementation(project(":switchycompose:multiplatform"))
82 |
83 | implementation(compose.runtime)
84 | implementation(compose.foundation)
85 | implementation(compose.material3)
86 | implementation(compose.ui)
87 | implementation(compose.components.resources)
88 | implementation(compose.components.uiToolingPreview)
89 | implementation(compose.materialIconsExtended)
90 | implementation(libs.androidx.lifecycle.viewmodel)
91 | implementation(libs.androidx.lifecycle.runtimeCompose)
92 | }
93 | commonTest.dependencies {
94 | implementation(libs.kotlin.test)
95 | }
96 | desktopMain.dependencies {
97 | implementation(compose.desktop.currentOs)
98 | implementation(libs.kotlinx.coroutinesSwing)
99 | }
100 | }
101 | }
102 |
103 | android {
104 | namespace = "dev.muazkadan.switchycomposedemo"
105 | compileSdk = libs.versions.android.compileSdk.get().toInt()
106 |
107 | defaultConfig {
108 | applicationId = "dev.muazkadan.switchycomposedemo"
109 | minSdk = libs.versions.android.minSdk.get().toInt()
110 | targetSdk = libs.versions.android.compileSdk.get().toInt()
111 | versionCode = 1
112 | versionName = "1.0"
113 | }
114 | packaging {
115 | resources {
116 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
117 | }
118 | }
119 | buildTypes {
120 | getByName("release") {
121 | isMinifyEnabled = false
122 | }
123 | }
124 | compileOptions {
125 | sourceCompatibility = JavaVersion.VERSION_11
126 | targetCompatibility = JavaVersion.VERSION_11
127 | }
128 | }
129 |
130 | dependencies {
131 | debugImplementation(compose.uiTooling)
132 | }
133 |
134 | compose.desktop {
135 | application {
136 | mainClass = "dev.muazkadan.switchycomposedemo.MainKt"
137 |
138 | nativeDistributions {
139 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
140 | packageName = "dev.muazkadan.switchycomposedemo"
141 | packageVersion = "1.0.0"
142 | }
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonTest/kotlin/dev/muazkadan/switchycompose/MorphingSwitchTest.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.remember
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.platform.testTag
8 | import androidx.compose.ui.test.ExperimentalTestApi
9 | import androidx.compose.ui.test.assertIsDisplayed
10 | import androidx.compose.ui.test.assertIsOff
11 | import androidx.compose.ui.test.assertIsOn
12 | import androidx.compose.ui.test.assertIsNotEnabled
13 | import androidx.compose.ui.test.onNodeWithTag
14 | import androidx.compose.ui.test.performClick
15 | import androidx.compose.ui.test.runComposeUiTest
16 | import kotlin.test.Test
17 | import kotlin.test.assertEquals
18 |
19 | @OptIn(ExperimentalTestApi::class)
20 | class MorphingSwitchTest {
21 |
22 | @Test
23 | fun testInitialStateUnchecked() = runComposeUiTest {
24 | setContent {
25 | MaterialTheme {
26 | MorphingSwitch(
27 | checked = false,
28 | onCheckedChange = {},
29 | modifier = Modifier.testTag("switchUnchecked"),
30 | )
31 | }
32 | }
33 | onNodeWithTag("switchUnchecked").assertIsDisplayed().assertIsOff()
34 | }
35 |
36 | @Test
37 | fun testInitialStateChecked() = runComposeUiTest {
38 | setContent {
39 | MaterialTheme {
40 | MorphingSwitch(
41 | checked = true,
42 | onCheckedChange = {},
43 | modifier = Modifier.testTag("switchChecked")
44 | )
45 | }
46 | }
47 | onNodeWithTag("switchChecked").assertIsDisplayed().assertIsOn()
48 | }
49 |
50 | @Test
51 | fun testDisabledStateUnchecked() = runComposeUiTest {
52 | setContent {
53 | MaterialTheme {
54 | MorphingSwitch(
55 | checked = false,
56 | onCheckedChange = {},
57 | enabled = false,
58 | modifier = Modifier.testTag("switchDisabledUnchecked")
59 | )
60 | }
61 | }
62 | onNodeWithTag("switchDisabledUnchecked").assertIsNotEnabled().assertIsOff()
63 | }
64 |
65 | @Test
66 | fun testDisabledStateChecked() = runComposeUiTest {
67 | setContent {
68 | MaterialTheme {
69 | MorphingSwitch(
70 | checked = true,
71 | onCheckedChange = {},
72 | enabled = false,
73 | modifier = Modifier.testTag("switchDisabledChecked")
74 | )
75 | }
76 | }
77 | onNodeWithTag("switchDisabledChecked").assertIsNotEnabled().assertIsOn()
78 | }
79 |
80 | @Test
81 | fun testStateChangeOnClick() = runComposeUiTest {
82 | setContent {
83 | MaterialTheme {
84 | val isChecked = remember { mutableStateOf(false) }
85 | MorphingSwitch(
86 | checked = isChecked.value,
87 | onCheckedChange = { isChecked.value = it },
88 | modifier = Modifier.testTag("switch")
89 | )
90 | }
91 | }
92 | onNodeWithTag("switch").assertIsOff().performClick().assertIsOn()
93 | }
94 |
95 | @Test
96 | fun testOnCheckedChangeCallback() = runComposeUiTest {
97 | var callbackValue = false
98 | setContent {
99 | MaterialTheme {
100 | MorphingSwitch(
101 | checked = false,
102 | onCheckedChange = { callbackValue = it },
103 | modifier = Modifier.testTag("switch")
104 | )
105 | }
106 | }
107 | onNodeWithTag("switch").performClick()
108 | assertEquals(true, callbackValue)
109 | }
110 |
111 | @Test
112 | fun testConfigurationNoPulseAnimation() = runComposeUiTest {
113 | setContent {
114 | MaterialTheme {
115 | val isChecked = remember { mutableStateOf(false) }
116 | MorphingSwitch(
117 | checked = isChecked.value,
118 | onCheckedChange = { isChecked.value = it },
119 | enablePulseAnimation = false,
120 | modifier = Modifier.testTag("switchNoPulse")
121 | )
122 | }
123 | }
124 | onNodeWithTag("switchNoPulse").assertIsDisplayed().assertIsOff().performClick().assertIsOn()
125 | }
126 |
127 | @Test
128 | fun testConfigurationNoMorphing() = runComposeUiTest {
129 | setContent {
130 | MaterialTheme {
131 | val isChecked = remember { mutableStateOf(false) }
132 | MorphingSwitch(
133 | checked = isChecked.value,
134 | onCheckedChange = { isChecked.value = it },
135 | enableMorphing = false,
136 | modifier = Modifier.testTag("switchNoMorphing")
137 | )
138 | }
139 | }
140 | onNodeWithTag("switchNoMorphing").assertIsDisplayed().assertIsOff().performClick().assertIsOn()
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonTest/kotlin/dev/muazkadan/switchycompose/ISwitchTest.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.platform.testTag
7 | import androidx.compose.ui.test.ExperimentalTestApi
8 | import androidx.compose.ui.test.assertIsDisplayed
9 | import androidx.compose.ui.test.assertIsOff
10 | import androidx.compose.ui.test.assertIsOn
11 | import androidx.compose.ui.test.assertIsNotEnabled
12 | import androidx.compose.ui.test.onNodeWithTag
13 | import androidx.compose.ui.test.performClick
14 | import androidx.compose.ui.test.runComposeUiTest
15 | import kotlin.test.Test
16 | import kotlin.test.assertEquals
17 |
18 | @OptIn(ExperimentalTestApi::class)
19 | class ISwitchTest {
20 |
21 | private val switchTag = "iSwitch"
22 |
23 | @Test
24 | fun testInitialStateUnchecked() = runComposeUiTest {
25 | setContent {
26 | MaterialTheme {
27 | ISwitch(
28 | modifier = Modifier.testTag(switchTag),
29 | checked = false,
30 | onCheckedChange = { }
31 | )
32 | }
33 | }
34 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOff()
35 | }
36 |
37 | @Test
38 | fun testInitialStateChecked() = runComposeUiTest {
39 | setContent {
40 | MaterialTheme {
41 | ISwitch(
42 | modifier = Modifier.testTag(switchTag),
43 | checked = true,
44 | onCheckedChange = { }
45 | )
46 | }
47 | }
48 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOn()
49 | }
50 |
51 | @Test
52 | fun testToggle() = runComposeUiTest {
53 | val checkedState = mutableStateOf(false)
54 | setContent {
55 | MaterialTheme {
56 | ISwitch(
57 | modifier = Modifier.testTag(switchTag),
58 | checked = checkedState.value,
59 | onCheckedChange = { checkedState.value = it }
60 | )
61 | }
62 | }
63 |
64 | // Initial state: Off
65 | onNodeWithTag(switchTag).assertIsOff()
66 | assertEquals(false, checkedState.value)
67 |
68 | // Click to turn On
69 | onNodeWithTag(switchTag).performClick()
70 | onNodeWithTag(switchTag).assertIsOn()
71 | assertEquals(true, checkedState.value)
72 |
73 | // Click to turn Off
74 | onNodeWithTag(switchTag).performClick()
75 | onNodeWithTag(switchTag).assertIsOff()
76 | assertEquals(false, checkedState.value)
77 | }
78 |
79 | @Test
80 | fun testOnCheckedChangeCallback() = runComposeUiTest {
81 | var callbackValue: Boolean? = null // Use nullable to ensure it's set by the callback
82 | val checkedState = mutableStateOf(false)
83 | setContent {
84 | MaterialTheme {
85 | ISwitch(
86 | modifier = Modifier.testTag(switchTag),
87 | checked = checkedState.value,
88 | onCheckedChange = {
89 | callbackValue = it
90 | checkedState.value = it
91 | }
92 | )
93 | }
94 | }
95 |
96 | // Click to toggle
97 | onNodeWithTag(switchTag).performClick()
98 | assertEquals(true, callbackValue)
99 |
100 | // Click again to toggle back
101 | onNodeWithTag(switchTag).performClick()
102 | assertEquals(false, callbackValue)
103 | }
104 |
105 | @Test
106 | fun testDisabledStateUnchecked() = runComposeUiTest {
107 | val checkedState = mutableStateOf(false)
108 | var callbackCalled = false
109 | setContent {
110 | MaterialTheme {
111 | ISwitch(
112 | modifier = Modifier.testTag(switchTag),
113 | checked = checkedState.value,
114 | onCheckedChange = {
115 | checkedState.value = it
116 | callbackCalled = true
117 | },
118 | enabled = false
119 | )
120 | }
121 | }
122 |
123 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOff()
124 |
125 | // Attempt to click, should not change state or call callback
126 | onNodeWithTag(switchTag).performClick()
127 | onNodeWithTag(switchTag).assertIsOff()
128 | assertEquals(false, checkedState.value)
129 | assertEquals(false, callbackCalled)
130 | }
131 |
132 | @Test
133 | fun testDisabledStateChecked() = runComposeUiTest {
134 | val checkedState = mutableStateOf(true)
135 | var callbackCalled = false
136 | setContent {
137 | MaterialTheme {
138 | ISwitch(
139 | modifier = Modifier.testTag(switchTag),
140 | checked = checkedState.value,
141 | onCheckedChange = {
142 | checkedState.value = it
143 | callbackCalled = true
144 | },
145 | enabled = false
146 | )
147 | }
148 | }
149 |
150 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOn()
151 |
152 | // Attempt to click, should not change state or call callback
153 | onNodeWithTag(switchTag).performClick()
154 | onNodeWithTag(switchTag).assertIsOn()
155 | assertEquals(true, checkedState.value)
156 | assertEquals(false, callbackCalled)
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/app/src/androidMain/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonTest/kotlin/dev/muazkadan/switchycompose/SquareSwitchTest.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.platform.testTag
7 | import androidx.compose.ui.test.ExperimentalTestApi
8 | import androidx.compose.ui.test.assertIsDisplayed
9 | import androidx.compose.ui.test.assertIsOff
10 | import androidx.compose.ui.test.assertIsOn
11 | import androidx.compose.ui.test.assertIsNotEnabled
12 | import androidx.compose.ui.test.onNodeWithTag
13 | import androidx.compose.ui.test.performClick
14 | import androidx.compose.ui.test.runComposeUiTest
15 | import kotlin.test.Test
16 | import kotlin.test.assertEquals
17 |
18 | @OptIn(ExperimentalTestApi::class)
19 | class SquareSwitchTest {
20 |
21 | private val switchTag = "squareSwitch"
22 |
23 | @Test
24 | fun testInitialStateUnchecked() = runComposeUiTest {
25 | setContent {
26 | MaterialTheme {
27 | SquareSwitch(
28 | modifier = Modifier.testTag(switchTag),
29 | checked = false,
30 | onCheckedChange = { }
31 | )
32 | }
33 | }
34 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOff()
35 | }
36 |
37 | @Test
38 | fun testInitialStateChecked() = runComposeUiTest {
39 | setContent {
40 | MaterialTheme {
41 | SquareSwitch(
42 | modifier = Modifier.testTag(switchTag),
43 | checked = true,
44 | onCheckedChange = { }
45 | )
46 | }
47 | }
48 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOn()
49 | }
50 |
51 | @Test
52 | fun testToggle() = runComposeUiTest {
53 | val checkedState = mutableStateOf(false)
54 | setContent {
55 | MaterialTheme {
56 | SquareSwitch(
57 | modifier = Modifier.testTag(switchTag),
58 | checked = checkedState.value,
59 | onCheckedChange = { checkedState.value = it }
60 | )
61 | }
62 | }
63 |
64 | // Initial state: Off
65 | onNodeWithTag(switchTag).assertIsOff()
66 | assertEquals(false, checkedState.value)
67 |
68 | // Click to turn On
69 | onNodeWithTag(switchTag).performClick()
70 | onNodeWithTag(switchTag).assertIsOn()
71 | assertEquals(true, checkedState.value)
72 |
73 | // Click to turn Off
74 | onNodeWithTag(switchTag).performClick()
75 | onNodeWithTag(switchTag).assertIsOff()
76 | assertEquals(false, checkedState.value)
77 | }
78 |
79 | @Test
80 | fun testOnCheckedChangeCallback() = runComposeUiTest {
81 | var callbackValue: Boolean? = null
82 | val checkedState = mutableStateOf(false)
83 | setContent {
84 | MaterialTheme {
85 | SquareSwitch(
86 | modifier = Modifier.testTag(switchTag),
87 | checked = checkedState.value,
88 | onCheckedChange = {
89 | callbackValue = it
90 | checkedState.value = it
91 | }
92 | )
93 | }
94 | }
95 |
96 | // Click to toggle
97 | onNodeWithTag(switchTag).performClick()
98 | assertEquals(true, callbackValue)
99 |
100 | // Click again to toggle back
101 | onNodeWithTag(switchTag).performClick()
102 | assertEquals(false, callbackValue)
103 | }
104 |
105 | @Test
106 | fun testDisabledStateUnchecked() = runComposeUiTest {
107 | val checkedState = mutableStateOf(false)
108 | var callbackCalled = false
109 | setContent {
110 | MaterialTheme {
111 | SquareSwitch(
112 | modifier = Modifier.testTag(switchTag),
113 | checked = checkedState.value,
114 | onCheckedChange = {
115 | checkedState.value = it
116 | callbackCalled = true
117 | },
118 | enabled = false
119 | )
120 | }
121 | }
122 |
123 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOff()
124 |
125 | // Attempt to click, should not change state or call callback
126 | onNodeWithTag(switchTag).performClick()
127 | onNodeWithTag(switchTag).assertIsOff()
128 | assertEquals(false, checkedState.value)
129 | assertEquals(false, callbackCalled)
130 | }
131 |
132 | @Test
133 | fun testDisabledStateChecked() = runComposeUiTest {
134 | val checkedState = mutableStateOf(true)
135 | var callbackCalled = false
136 | setContent {
137 | MaterialTheme {
138 | SquareSwitch(
139 | modifier = Modifier.testTag(switchTag),
140 | checked = checkedState.value,
141 | onCheckedChange = {
142 | checkedState.value = it
143 | callbackCalled = true
144 | },
145 | enabled = false
146 | )
147 | }
148 | }
149 |
150 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOn()
151 |
152 | // Attempt to click, should not change state or call callback
153 | onNodeWithTag(switchTag).performClick()
154 | onNodeWithTag(switchTag).assertIsOn()
155 | assertEquals(true, checkedState.value)
156 | assertEquals(false, callbackCalled)
157 | }
158 |
159 | @Test
160 | fun testNullOnCheckedChangeCallback() = runComposeUiTest {
161 | setContent {
162 | MaterialTheme {
163 | SquareSwitch(
164 | modifier = Modifier.testTag(switchTag),
165 | checked = false,
166 | onCheckedChange = null
167 | )
168 | }
169 | }
170 |
171 | // Should be displayed
172 | // Note: We can't use assertIsOff() because the component doesn't have toggleable
173 | // semantics when onCheckedChange is null
174 | onNodeWithTag(switchTag).assertIsDisplayed()
175 |
176 | // Click should be a no-op since callback is null
177 | onNodeWithTag(switchTag).performClick()
178 |
179 | // Should still be displayed after click
180 | onNodeWithTag(switchTag).assertIsDisplayed()
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | build
3 | captures
4 | .cxx
5 | xcuserdata
6 | .kotlin
7 |
8 | **/build/
9 | !src/**/build/
10 | *.xcodeproj/*
11 | !*.xcodeproj/project.pbxproj
12 | !*.xcodeproj/xcshareddata/
13 | !*.xcodeproj/project.xcworkspace/
14 | !*.xcworkspace/contents.xcworkspacedata
15 | **/xcshareddata/WorkspaceSettings.xcsettings
16 |
17 | #So we don't accidentally commit our private keys
18 | *.gpg
19 |
20 | # Created by https://www.toptal.com/developers/gitignore/api/android,androidstudio,java,kotlin,macos,windows
21 | # Edit at https://www.toptal.com/developers/gitignore?templates=android,androidstudio,java,kotlin,macos,windows
22 |
23 | ### Android ###
24 | # Built application files
25 | *.apk
26 | *.aar
27 | *.ap_
28 | *.aab
29 |
30 | # Files for the ART/Dalvik VM
31 | *.dex
32 |
33 | # Java class files
34 | *.class
35 |
36 | # Generated files
37 | bin/
38 | gen/
39 | out/
40 | # Uncomment the following line in case you need and you don't have the release build type files in your app
41 | # release/
42 |
43 | # Gradle files
44 | .gradle/
45 | build/
46 |
47 | # Local configuration file (sdk path, etc)
48 | local.properties
49 |
50 | # Proguard folder generated by Eclipse
51 | proguard/
52 |
53 | # Log Files
54 | *.log
55 |
56 | # Android Studio Navigation editor temp files
57 | .navigation/
58 |
59 | # Android Studio captures folder
60 | captures/
61 |
62 | # IntelliJ
63 | *.iml
64 | .idea/workspace.xml
65 | .idea/tasks.xml
66 | .idea/gradle.xml
67 | .idea/assetWizardSettings.xml
68 | .idea/dictionaries
69 | .idea/libraries
70 | # Android Studio 3 in .gitignore file.
71 | .idea/caches
72 | .idea/modules.xml
73 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you
74 | .idea/navEditor.xml
75 |
76 | # Keystore files
77 | # Uncomment the following lines if you do not want to check your keystore files in.
78 | #*.jks
79 | #*.keystore
80 |
81 | # External native build folder generated in Android Studio 2.2 and later
82 | .externalNativeBuild
83 | .cxx/
84 |
85 | # Google Services (e.g. APIs or Firebase)
86 | # google-services.json
87 |
88 | # Freeline
89 | freeline.py
90 | freeline/
91 | freeline_project_description.json
92 |
93 | # fastlane
94 | fastlane/report.xml
95 | fastlane/Preview.html
96 | fastlane/screenshots
97 | fastlane/test_output
98 | fastlane/readme.md
99 |
100 | # Version control
101 | vcs.xml
102 |
103 | # lint
104 | lint/intermediates/
105 | lint/generated/
106 | lint/outputs/
107 | lint/tmp/
108 | # lint/reports/
109 |
110 | ### Android Patch ###
111 | gen-external-apklibs
112 | output.json
113 |
114 | # Replacement of .externalNativeBuild directories introduced
115 | # with Android Studio 3.5.
116 |
117 | ### Java ###
118 | # Compiled class file
119 |
120 | # Log file
121 |
122 | # BlueJ files
123 | *.ctxt
124 |
125 | # Mobile Tools for Java (J2ME)
126 | .mtj.tmp/
127 |
128 | # Package Files #
129 | *.jar
130 | *.war
131 | *.nar
132 | *.ear
133 | *.zip
134 | *.tar.gz
135 | *.rar
136 |
137 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
138 | hs_err_pid*
139 |
140 | ### Kotlin ###
141 | # Compiled class file
142 |
143 | # Log file
144 |
145 | # BlueJ files
146 |
147 | # Mobile Tools for Java (J2ME)
148 |
149 | # Package Files #
150 |
151 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
152 |
153 | ### macOS ###
154 | # General
155 | .DS_Store
156 | .AppleDouble
157 | .LSOverride
158 |
159 | # Icon must end with two \r
160 | Icon
161 |
162 |
163 | # Thumbnails
164 | ._*
165 |
166 | # Files that might appear in the root of a volume
167 | .DocumentRevisions-V100
168 | .fseventsd
169 | .Spotlight-V100
170 | .TemporaryItems
171 | .Trashes
172 | .VolumeIcon.icns
173 | .com.apple.timemachine.donotpresent
174 |
175 | # Directories potentially created on remote AFP share
176 | .AppleDB
177 | .AppleDesktop
178 | Network Trash Folder
179 | Temporary Items
180 | .apdisk
181 |
182 | ### Windows ###
183 | # Windows thumbnail cache files
184 | Thumbs.db
185 | Thumbs.db:encryptable
186 | ehthumbs.db
187 | ehthumbs_vista.db
188 |
189 | # Dump file
190 | *.stackdump
191 |
192 | # Folder config file
193 | [Dd]esktop.ini
194 |
195 | # Recycle Bin used on file shares
196 | $RECYCLE.BIN/
197 |
198 | # Windows Installer files
199 | *.cab
200 | *.msi
201 | *.msix
202 | *.msm
203 | *.msp
204 |
205 | # Windows shortcuts
206 | *.lnk
207 |
208 | ### AndroidStudio ###
209 | # Covers files to be ignored for android development using Android Studio.
210 |
211 | # Built application files
212 |
213 | # Files for the ART/Dalvik VM
214 |
215 | # Java class files
216 |
217 | # Generated files
218 |
219 | # Gradle files
220 | .gradle
221 |
222 | # Signing files
223 | .signing/
224 |
225 | # Local configuration file (sdk path, etc)
226 |
227 | # Proguard folder generated by Eclipse
228 |
229 | # Log Files
230 |
231 | # Android Studio
232 | /*/build/
233 | /*/local.properties
234 | /*/out
235 | /*/*/build
236 | /*/*/production
237 | *.ipr
238 | *~
239 | *.swp
240 |
241 | # Keystore files
242 | *.jks
243 | *.keystore
244 |
245 | # Google Services (e.g. APIs or Firebase)
246 | # google-services.json
247 |
248 | # Android Patch
249 |
250 | # External native build folder generated in Android Studio 2.2 and later
251 |
252 | # NDK
253 | obj/
254 |
255 | # IntelliJ IDEA
256 | *.iws
257 | /out/
258 |
259 | # User-specific configurations
260 | .idea/caches/
261 | .idea/libraries/
262 | .idea/shelf/
263 | .idea/.name
264 | .idea/compiler.xml
265 | .idea/copyright/profiles_settings.xml
266 | .idea/encodings.xml
267 | .idea/misc.xml
268 | .idea/scopes/scope_settings.xml
269 | .idea/vcs.xml
270 | .idea/jsLibraryMappings.xml
271 | .idea/datasources.xml
272 | .idea/dataSources.ids
273 | .idea/sqlDataSources.xml
274 | .idea/dynamic.xml
275 | .idea/uiDesigner.xml
276 | .idea/jarRepositories.xml
277 |
278 | # OS-specific files
279 | .DS_Store?
280 |
281 | # Legacy Eclipse project files
282 | .classpath
283 | .project
284 | .cproject
285 | .settings/
286 |
287 | # Mobile Tools for Java (J2ME)
288 |
289 | # Package Files #
290 |
291 | # virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
292 |
293 | ## Plugin-specific files:
294 |
295 | # mpeltonen/sbt-idea plugin
296 | .idea_modules/
297 |
298 | # JIRA plugin
299 | atlassian-ide-plugin.xml
300 |
301 | # Mongo Explorer plugin
302 | .idea/mongoSettings.xml
303 |
304 | # Crashlytics plugin (for Android Studio and IntelliJ)
305 | com_crashlytics_export_strings.xml
306 | crashlytics.properties
307 | crashlytics-build.properties
308 | fabric.properties
309 |
310 | ### AndroidStudio Patch ###
311 |
312 | !/gradle/wrapper/gradle-wrapper.jar
313 |
314 | /.idea/deploymentTargetDropDown.xml
315 | # End of https://www.toptal.com/developers/gitignore/api/android,androidstudio,java,kotlin,macos,windows
316 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonTest/kotlin/dev/muazkadan/switchycompose/NativeSwitchTest.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.platform.testTag
7 | import androidx.compose.ui.test.ExperimentalTestApi
8 | import androidx.compose.ui.test.assertIsDisplayed
9 | import androidx.compose.ui.test.assertIsOff
10 | import androidx.compose.ui.test.assertIsOn
11 | import androidx.compose.ui.test.assertIsNotEnabled
12 | import androidx.compose.ui.test.onNodeWithTag
13 | import androidx.compose.ui.test.performClick
14 | import androidx.compose.ui.test.runComposeUiTest
15 | import kotlin.test.Test
16 | import kotlin.test.assertEquals
17 |
18 | @OptIn(ExperimentalTestApi::class)
19 | class NativeSwitchTest {
20 |
21 | private val switchTag = "nativeSwitch"
22 |
23 | @Test
24 | fun testInitialStateUnchecked() = runComposeUiTest {
25 | setContent {
26 | MaterialTheme {
27 | NativeSwitch(
28 | modifier = Modifier.testTag(switchTag),
29 | checked = false,
30 | onCheckedChange = { }
31 | )
32 | }
33 | }
34 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOff()
35 | }
36 |
37 | @Test
38 | fun testInitialStateChecked() = runComposeUiTest {
39 | setContent {
40 | MaterialTheme {
41 | NativeSwitch(
42 | modifier = Modifier.testTag(switchTag),
43 | checked = true,
44 | onCheckedChange = { }
45 | )
46 | }
47 | }
48 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOn()
49 | }
50 |
51 | @Test
52 | fun testToggle() = runComposeUiTest {
53 | val checkedState = mutableStateOf(false)
54 | setContent {
55 | MaterialTheme {
56 | NativeSwitch(
57 | modifier = Modifier.testTag(switchTag),
58 | checked = checkedState.value,
59 | onCheckedChange = { checkedState.value = it }
60 | )
61 | }
62 | }
63 |
64 | // Initial state: Off
65 | onNodeWithTag(switchTag).assertIsOff()
66 | assertEquals(false, checkedState.value)
67 |
68 | // Click to turn On
69 | onNodeWithTag(switchTag).performClick()
70 | onNodeWithTag(switchTag).assertIsOn()
71 | assertEquals(true, checkedState.value)
72 |
73 | // Click to turn Off
74 | onNodeWithTag(switchTag).performClick()
75 | onNodeWithTag(switchTag).assertIsOff()
76 | assertEquals(false, checkedState.value)
77 | }
78 |
79 | @Test
80 | fun testOnCheckedChangeCallback() = runComposeUiTest {
81 | var callbackValue: Boolean? = null
82 | val checkedState = mutableStateOf(false)
83 | setContent {
84 | MaterialTheme {
85 | NativeSwitch(
86 | modifier = Modifier.testTag(switchTag),
87 | checked = checkedState.value,
88 | onCheckedChange = {
89 | callbackValue = it
90 | checkedState.value = it
91 | }
92 | )
93 | }
94 | }
95 |
96 | // Click to toggle
97 | onNodeWithTag(switchTag).performClick()
98 | assertEquals(true, callbackValue)
99 |
100 | // Click again to toggle back
101 | onNodeWithTag(switchTag).performClick()
102 | assertEquals(false, callbackValue)
103 | }
104 |
105 | @Test
106 | fun testDisabledStateUnchecked() = runComposeUiTest {
107 | val checkedState = mutableStateOf(false)
108 | var callbackCalled = false
109 | setContent {
110 | MaterialTheme {
111 | NativeSwitch(
112 | modifier = Modifier.testTag(switchTag),
113 | checked = checkedState.value,
114 | onCheckedChange = {
115 | checkedState.value = it
116 | callbackCalled = true
117 | },
118 | enabled = false
119 | )
120 | }
121 | }
122 |
123 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOff()
124 |
125 | // Attempt to click, should not change state or call callback
126 | onNodeWithTag(switchTag).performClick()
127 | onNodeWithTag(switchTag).assertIsOff()
128 | assertEquals(false, checkedState.value)
129 | assertEquals(false, callbackCalled)
130 | }
131 |
132 | @Test
133 | fun testDisabledStateChecked() = runComposeUiTest {
134 | val checkedState = mutableStateOf(true)
135 | var callbackCalled = false
136 | setContent {
137 | MaterialTheme {
138 | NativeSwitch(
139 | modifier = Modifier.testTag(switchTag),
140 | checked = checkedState.value,
141 | onCheckedChange = {
142 | checkedState.value = it
143 | callbackCalled = true
144 | },
145 | enabled = false
146 | )
147 | }
148 | }
149 |
150 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOn()
151 |
152 | // Attempt to click, should not change state or call callback
153 | onNodeWithTag(switchTag).performClick()
154 | onNodeWithTag(switchTag).assertIsOn()
155 | assertEquals(true, checkedState.value)
156 | assertEquals(false, callbackCalled)
157 | }
158 |
159 | @Test
160 | fun testNullOnCheckedChangeCallback() = runComposeUiTest {
161 | setContent {
162 | MaterialTheme {
163 | NativeSwitch(
164 | modifier = Modifier.testTag(switchTag),
165 | checked = false,
166 | onCheckedChange = null
167 | )
168 | }
169 | }
170 |
171 | // Should be displayed
172 | // Note: We can't use assertIsOff() because the component doesn't have toggleable
173 | // semantics when onCheckedChange is null
174 | onNodeWithTag(switchTag).assertIsDisplayed()
175 |
176 | // Click should be a no-op since callback is null
177 | onNodeWithTag(switchTag).performClick()
178 |
179 | // Should still be displayed after click
180 | onNodeWithTag(switchTag).assertIsDisplayed()
181 | }
182 |
183 | @Test
184 | fun testDefaultParameters() = runComposeUiTest {
185 | setContent {
186 | MaterialTheme {
187 | NativeSwitch(
188 | modifier = Modifier.testTag(switchTag),
189 | checked = false
190 | )
191 | }
192 | }
193 |
194 | // Should be displayed with default parameters
195 | // Note: We can't use assertIsOff() because the default implementation
196 | // doesn't have toggleable semantics (onCheckedChange is null by default)
197 | onNodeWithTag(switchTag).assertIsDisplayed()
198 | }
199 | }
200 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonTest/kotlin/dev/muazkadan/switchycompose/HeartSwitchTest.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.graphics.Color
7 | import androidx.compose.ui.platform.testTag
8 | import androidx.compose.ui.test.ExperimentalTestApi
9 | import androidx.compose.ui.test.assertIsDisplayed
10 | import androidx.compose.ui.test.assertIsOff
11 | import androidx.compose.ui.test.assertIsOn
12 | import androidx.compose.ui.test.assertIsNotEnabled
13 | import androidx.compose.ui.test.onNodeWithTag
14 | import androidx.compose.ui.test.performClick
15 | import androidx.compose.ui.test.runComposeUiTest
16 | import kotlin.test.Test
17 | import kotlin.test.assertEquals
18 |
19 | @OptIn(ExperimentalTestApi::class)
20 | class HeartSwitchTest {
21 |
22 | private val switchTag = "heartSwitch"
23 |
24 | @Test
25 | fun testInitialStateUnchecked() = runComposeUiTest {
26 | setContent {
27 | MaterialTheme {
28 | HeartSwitch(
29 | modifier = Modifier.testTag(switchTag),
30 | checked = false,
31 | onCheckedChange = { }
32 | )
33 | }
34 | }
35 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOff()
36 | }
37 |
38 | @Test
39 | fun testInitialStateChecked() = runComposeUiTest {
40 | setContent {
41 | MaterialTheme {
42 | HeartSwitch(
43 | modifier = Modifier.testTag(switchTag),
44 | checked = true,
45 | onCheckedChange = { }
46 | )
47 | }
48 | }
49 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOn()
50 | }
51 |
52 | @Test
53 | fun testToggle() = runComposeUiTest {
54 | val checkedState = mutableStateOf(false)
55 | setContent {
56 | MaterialTheme {
57 | HeartSwitch(
58 | modifier = Modifier.testTag(switchTag),
59 | checked = checkedState.value,
60 | onCheckedChange = { checkedState.value = it }
61 | )
62 | }
63 | }
64 |
65 | // Initial state: Off
66 | onNodeWithTag(switchTag).assertIsOff()
67 | assertEquals(false, checkedState.value)
68 |
69 | // Click to turn On
70 | onNodeWithTag(switchTag).performClick()
71 | onNodeWithTag(switchTag).assertIsOn()
72 | assertEquals(true, checkedState.value)
73 |
74 | // Click to turn Off
75 | onNodeWithTag(switchTag).performClick()
76 | onNodeWithTag(switchTag).assertIsOff()
77 | assertEquals(false, checkedState.value)
78 | }
79 |
80 | @Test
81 | fun testOnCheckedChangeCallback() = runComposeUiTest {
82 | var callbackValue: Boolean? = null
83 | val checkedState = mutableStateOf(false)
84 | setContent {
85 | MaterialTheme {
86 | HeartSwitch(
87 | modifier = Modifier.testTag(switchTag),
88 | checked = checkedState.value,
89 | onCheckedChange = {
90 | callbackValue = it
91 | checkedState.value = it
92 | }
93 | )
94 | }
95 | }
96 |
97 | // Click to toggle
98 | onNodeWithTag(switchTag).performClick()
99 | assertEquals(true, callbackValue)
100 |
101 | // Click again to toggle back
102 | onNodeWithTag(switchTag).performClick()
103 | assertEquals(false, callbackValue)
104 | }
105 |
106 | @Test
107 | fun testDisabledStateUnchecked() = runComposeUiTest {
108 | val checkedState = mutableStateOf(false)
109 | var callbackCalled = false
110 | setContent {
111 | MaterialTheme {
112 | HeartSwitch(
113 | modifier = Modifier.testTag(switchTag),
114 | checked = checkedState.value,
115 | onCheckedChange = {
116 | checkedState.value = it
117 | callbackCalled = true
118 | },
119 | enabled = false
120 | )
121 | }
122 | }
123 |
124 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOff()
125 |
126 | // Attempt to click, should not change state or call callback
127 | onNodeWithTag(switchTag).performClick()
128 | onNodeWithTag(switchTag).assertIsOff()
129 | assertEquals(false, checkedState.value)
130 | assertEquals(false, callbackCalled)
131 | }
132 |
133 | @Test
134 | fun testDisabledStateChecked() = runComposeUiTest {
135 | val checkedState = mutableStateOf(true)
136 | var callbackCalled = false
137 | setContent {
138 | MaterialTheme {
139 | HeartSwitch(
140 | modifier = Modifier.testTag(switchTag),
141 | checked = checkedState.value,
142 | onCheckedChange = {
143 | checkedState.value = it
144 | callbackCalled = true
145 | },
146 | enabled = false
147 | )
148 | }
149 | }
150 |
151 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOn()
152 |
153 | // Attempt to click, should not change state or call callback
154 | onNodeWithTag(switchTag).performClick()
155 | onNodeWithTag(switchTag).assertIsOn()
156 | assertEquals(true, checkedState.value)
157 | assertEquals(false, callbackCalled)
158 | }
159 |
160 | @Test
161 | fun testNullOnCheckedChangeCallback() = runComposeUiTest {
162 | setContent {
163 | MaterialTheme {
164 | HeartSwitch(
165 | modifier = Modifier.testTag(switchTag),
166 | checked = false,
167 | onCheckedChange = null
168 | )
169 | }
170 | }
171 |
172 | // Should be displayed
173 | onNodeWithTag(switchTag).assertIsDisplayed()
174 |
175 | // Click should be a no-op since callback is null
176 | onNodeWithTag(switchTag).performClick()
177 |
178 | // Should still be displayed after click
179 | onNodeWithTag(switchTag).assertIsDisplayed()
180 | }
181 |
182 | @Test
183 | fun testCustomColors() = runComposeUiTest {
184 | val checkedState = mutableStateOf(false)
185 | setContent {
186 | MaterialTheme {
187 | HeartSwitch(
188 | modifier = Modifier.testTag(switchTag),
189 | checked = checkedState.value,
190 | onCheckedChange = { checkedState.value = it },
191 | positiveColor = Color.Red,
192 | negativeColor = Color.White,
193 | checkedBorderColor = Color.Magenta,
194 | uncheckedBorderColor = Color.Gray,
195 | thumbColor = Color.Yellow,
196 | uncheckedThumbColor = Color.Cyan
197 | )
198 | }
199 | }
200 |
201 | // Toggle to ensure custom colors don't break interaction
202 | onNodeWithTag(switchTag).assertIsOff().performClick().assertIsOn()
203 | }
204 | }
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonTest/kotlin/dev/muazkadan/switchycompose/TextSwitchTest.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.platform.testTag
7 | import androidx.compose.ui.test.ExperimentalTestApi
8 | import androidx.compose.ui.test.assertIsDisplayed
9 | import androidx.compose.ui.test.assertIsOff
10 | import androidx.compose.ui.test.assertIsOn
11 | import androidx.compose.ui.test.assertIsNotEnabled
12 | import androidx.compose.ui.test.onNodeWithTag
13 | import androidx.compose.ui.test.performClick
14 | import androidx.compose.ui.test.runComposeUiTest
15 | import kotlin.test.Test
16 | import kotlin.test.assertEquals
17 |
18 | @OptIn(ExperimentalTestApi::class)
19 | class TextSwitchTest {
20 |
21 | private val switchTag = "textSwitch"
22 |
23 | @Test
24 | fun testInitialStateUnchecked() = runComposeUiTest {
25 | setContent {
26 | MaterialTheme {
27 | TextSwitch(
28 | modifier = Modifier.testTag(switchTag),
29 | checked = false,
30 | onCheckedChange = { }
31 | )
32 | }
33 | }
34 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOff()
35 | }
36 |
37 | @Test
38 | fun testInitialStateChecked() = runComposeUiTest {
39 | setContent {
40 | MaterialTheme {
41 | TextSwitch(
42 | modifier = Modifier.testTag(switchTag),
43 | checked = true,
44 | onCheckedChange = { }
45 | )
46 | }
47 | }
48 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOn()
49 | }
50 |
51 | @Test
52 | fun testToggle() = runComposeUiTest {
53 | val checkedState = mutableStateOf(false)
54 | setContent {
55 | MaterialTheme {
56 | TextSwitch(
57 | modifier = Modifier.testTag(switchTag),
58 | checked = checkedState.value,
59 | onCheckedChange = { checkedState.value = it }
60 | )
61 | }
62 | }
63 |
64 | // Initial state: Off
65 | onNodeWithTag(switchTag).assertIsOff()
66 | assertEquals(false, checkedState.value)
67 |
68 | // Click to turn On
69 | onNodeWithTag(switchTag).performClick()
70 | onNodeWithTag(switchTag).assertIsOn()
71 | assertEquals(true, checkedState.value)
72 |
73 | // Click to turn Off
74 | onNodeWithTag(switchTag).performClick()
75 | onNodeWithTag(switchTag).assertIsOff()
76 | assertEquals(false, checkedState.value)
77 | }
78 |
79 | @Test
80 | fun testOnCheckedChangeCallback() = runComposeUiTest {
81 | var callbackValue: Boolean? = null
82 | val checkedState = mutableStateOf(false)
83 | setContent {
84 | MaterialTheme {
85 | TextSwitch(
86 | modifier = Modifier.testTag(switchTag),
87 | checked = checkedState.value,
88 | onCheckedChange = {
89 | callbackValue = it
90 | checkedState.value = it
91 | }
92 | )
93 | }
94 | }
95 |
96 | // Click to toggle
97 | onNodeWithTag(switchTag).performClick()
98 | assertEquals(true, callbackValue)
99 |
100 | // Click again to toggle back
101 | onNodeWithTag(switchTag).performClick()
102 | assertEquals(false, callbackValue)
103 | }
104 |
105 | @Test
106 | fun testDisabledStateUnchecked() = runComposeUiTest {
107 | val checkedState = mutableStateOf(false)
108 | var callbackCalled = false
109 | setContent {
110 | MaterialTheme {
111 | TextSwitch(
112 | modifier = Modifier.testTag(switchTag),
113 | checked = checkedState.value,
114 | onCheckedChange = {
115 | checkedState.value = it
116 | callbackCalled = true
117 | },
118 | enabled = false
119 | )
120 | }
121 | }
122 |
123 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOff()
124 |
125 | // Attempt to click, should not change state or call callback
126 | onNodeWithTag(switchTag).performClick()
127 | onNodeWithTag(switchTag).assertIsOff()
128 | assertEquals(false, checkedState.value)
129 | assertEquals(false, callbackCalled)
130 | }
131 |
132 | @Test
133 | fun testDisabledStateChecked() = runComposeUiTest {
134 | val checkedState = mutableStateOf(true)
135 | var callbackCalled = false
136 | setContent {
137 | MaterialTheme {
138 | TextSwitch(
139 | modifier = Modifier.testTag(switchTag),
140 | checked = checkedState.value,
141 | onCheckedChange = {
142 | checkedState.value = it
143 | callbackCalled = true
144 | },
145 | enabled = false
146 | )
147 | }
148 | }
149 |
150 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOn()
151 |
152 | // Attempt to click, should not change state or call callback
153 | onNodeWithTag(switchTag).performClick()
154 | onNodeWithTag(switchTag).assertIsOn()
155 | assertEquals(true, checkedState.value)
156 | assertEquals(false, callbackCalled)
157 | }
158 |
159 | @Test
160 | fun testNullOnCheckedChangeCallback() = runComposeUiTest {
161 | setContent {
162 | MaterialTheme {
163 | TextSwitch(
164 | modifier = Modifier.testTag(switchTag),
165 | checked = false,
166 | onCheckedChange = null
167 | )
168 | }
169 | }
170 |
171 | // Should be displayed
172 | // Note: We can't use assertIsOff() because the component doesn't have toggleable
173 | // semantics when onCheckedChange is null
174 | onNodeWithTag(switchTag).assertIsDisplayed()
175 |
176 | // Click should be a no-op since callback is null
177 | onNodeWithTag(switchTag).performClick()
178 |
179 | // Should still be displayed after click
180 | onNodeWithTag(switchTag).assertIsDisplayed()
181 | }
182 |
183 | @Test
184 | fun testCustomTextLabels() = runComposeUiTest {
185 | val checkedState = mutableStateOf(false)
186 | setContent {
187 | MaterialTheme {
188 | TextSwitch(
189 | modifier = Modifier.testTag(switchTag),
190 | checked = checkedState.value,
191 | onCheckedChange = { checkedState.value = it },
192 | positiveText = "ON",
193 | negativeText = "OFF"
194 | )
195 | }
196 | }
197 |
198 | // Initial state: Off
199 | onNodeWithTag(switchTag).assertIsOff()
200 | assertEquals(false, checkedState.value)
201 |
202 | // Click to turn On
203 | onNodeWithTag(switchTag).performClick()
204 | onNodeWithTag(switchTag).assertIsOn()
205 | assertEquals(true, checkedState.value)
206 | }
207 | }
208 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonTest/kotlin/dev/muazkadan/switchycompose/IconISwitchTest.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.material.icons.Icons
4 | import androidx.compose.material.icons.filled.Favorite
5 | import androidx.compose.material.icons.filled.FavoriteBorder
6 | import androidx.compose.material3.MaterialTheme
7 | import androidx.compose.runtime.mutableStateOf
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.platform.testTag
10 | import androidx.compose.ui.test.ExperimentalTestApi
11 | import androidx.compose.ui.test.assertIsDisplayed
12 | import androidx.compose.ui.test.assertIsOff
13 | import androidx.compose.ui.test.assertIsOn
14 | import androidx.compose.ui.test.assertIsNotEnabled
15 | import androidx.compose.ui.test.onNodeWithTag
16 | import androidx.compose.ui.test.performClick
17 | import androidx.compose.ui.test.runComposeUiTest
18 | import kotlin.test.Test
19 | import kotlin.test.assertEquals
20 |
21 | @OptIn(ExperimentalTestApi::class)
22 | class IconISwitchTest {
23 |
24 | private val switchTag = "iconISwitch"
25 |
26 | @Test
27 | fun testInitialStateUnchecked() = runComposeUiTest {
28 | setContent {
29 | MaterialTheme {
30 | IconISwitch(
31 | modifier = Modifier.testTag(switchTag),
32 | checked = false,
33 | onCheckedChange = { }
34 | )
35 | }
36 | }
37 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOff()
38 | }
39 |
40 | @Test
41 | fun testInitialStateChecked() = runComposeUiTest {
42 | setContent {
43 | MaterialTheme {
44 | IconISwitch(
45 | modifier = Modifier.testTag(switchTag),
46 | checked = true,
47 | onCheckedChange = { }
48 | )
49 | }
50 | }
51 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOn()
52 | }
53 |
54 | @Test
55 | fun testToggle() = runComposeUiTest {
56 | val checkedState = mutableStateOf(false)
57 | setContent {
58 | MaterialTheme {
59 | IconISwitch(
60 | modifier = Modifier.testTag(switchTag),
61 | checked = checkedState.value,
62 | onCheckedChange = { checkedState.value = it }
63 | )
64 | }
65 | }
66 |
67 | // Initial state: Off
68 | onNodeWithTag(switchTag).assertIsOff()
69 | assertEquals(false, checkedState.value)
70 |
71 | // Click to turn On
72 | onNodeWithTag(switchTag).performClick()
73 | onNodeWithTag(switchTag).assertIsOn()
74 | assertEquals(true, checkedState.value)
75 |
76 | // Click to turn Off
77 | onNodeWithTag(switchTag).performClick()
78 | onNodeWithTag(switchTag).assertIsOff()
79 | assertEquals(false, checkedState.value)
80 | }
81 |
82 | @Test
83 | fun testOnCheckedChangeCallback() = runComposeUiTest {
84 | var callbackValue: Boolean? = null
85 | val checkedState = mutableStateOf(false)
86 | setContent {
87 | MaterialTheme {
88 | IconISwitch(
89 | modifier = Modifier.testTag(switchTag),
90 | checked = checkedState.value,
91 | onCheckedChange = {
92 | callbackValue = it
93 | checkedState.value = it
94 | }
95 | )
96 | }
97 | }
98 |
99 | // Click to toggle
100 | onNodeWithTag(switchTag).performClick()
101 | assertEquals(true, callbackValue)
102 |
103 | // Click again to toggle back
104 | onNodeWithTag(switchTag).performClick()
105 | assertEquals(false, callbackValue)
106 | }
107 |
108 | @Test
109 | fun testDisabledStateUnchecked() = runComposeUiTest {
110 | val checkedState = mutableStateOf(false)
111 | var callbackCalled = false
112 | setContent {
113 | MaterialTheme {
114 | IconISwitch(
115 | modifier = Modifier.testTag(switchTag),
116 | checked = checkedState.value,
117 | onCheckedChange = {
118 | checkedState.value = it
119 | callbackCalled = true
120 | },
121 | enabled = false
122 | )
123 | }
124 | }
125 |
126 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOff()
127 |
128 | // Attempt to click, should not change state or call callback
129 | onNodeWithTag(switchTag).performClick()
130 | onNodeWithTag(switchTag).assertIsOff()
131 | assertEquals(false, checkedState.value)
132 | assertEquals(false, callbackCalled)
133 | }
134 |
135 | @Test
136 | fun testDisabledStateChecked() = runComposeUiTest {
137 | val checkedState = mutableStateOf(true)
138 | var callbackCalled = false
139 | setContent {
140 | MaterialTheme {
141 | IconISwitch(
142 | modifier = Modifier.testTag(switchTag),
143 | checked = checkedState.value,
144 | onCheckedChange = {
145 | checkedState.value = it
146 | callbackCalled = true
147 | },
148 | enabled = false
149 | )
150 | }
151 | }
152 |
153 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOn()
154 |
155 | // Attempt to click, should not change state or call callback
156 | onNodeWithTag(switchTag).performClick()
157 | onNodeWithTag(switchTag).assertIsOn()
158 | assertEquals(true, checkedState.value)
159 | assertEquals(false, callbackCalled)
160 | }
161 |
162 | @Test
163 | fun testNullOnCheckedChangeCallback() = runComposeUiTest {
164 | setContent {
165 | MaterialTheme {
166 | IconISwitch(
167 | modifier = Modifier.testTag(switchTag),
168 | checked = false,
169 | onCheckedChange = null
170 | )
171 | }
172 | }
173 |
174 | // Should be displayed
175 | // Note: We can't use assertIsOff() because the component doesn't have toggleable
176 | // semantics when onCheckedChange is null
177 | onNodeWithTag(switchTag).assertIsDisplayed()
178 |
179 | // Click should be a no-op since callback is null
180 | onNodeWithTag(switchTag).performClick()
181 |
182 | // Should still be displayed after click
183 | onNodeWithTag(switchTag).assertIsDisplayed()
184 | }
185 |
186 | @Test
187 | fun testCustomIcons() = runComposeUiTest {
188 | val checkedState = mutableStateOf(false)
189 | setContent {
190 | MaterialTheme {
191 | IconISwitch(
192 | modifier = Modifier.testTag(switchTag),
193 | checked = checkedState.value,
194 | onCheckedChange = { checkedState.value = it },
195 | positiveIcon = Icons.Default.Favorite,
196 | negativeIcon = Icons.Default.FavoriteBorder
197 | )
198 | }
199 | }
200 |
201 | // Initial state: Off
202 | onNodeWithTag(switchTag).assertIsOff()
203 | assertEquals(false, checkedState.value)
204 |
205 | // Click to turn On
206 | onNodeWithTag(switchTag).performClick()
207 | onNodeWithTag(switchTag).assertIsOn()
208 | assertEquals(true, checkedState.value)
209 | }
210 | }
211 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonMain/kotlin/dev/muazkadan/switchycompose/ISwitch.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.animation.animateColorAsState
4 | import androidx.compose.animation.core.FastOutSlowInEasing
5 | import androidx.compose.animation.core.Spring
6 | import androidx.compose.animation.core.animateDpAsState
7 | import androidx.compose.animation.core.animateFloatAsState
8 | import androidx.compose.animation.core.spring
9 | import androidx.compose.animation.core.tween
10 | import androidx.compose.foundation.background
11 | import androidx.compose.foundation.interaction.MutableInteractionSource
12 | import androidx.compose.foundation.layout.*
13 | import androidx.compose.foundation.selection.toggleable
14 | import androidx.compose.foundation.shape.RoundedCornerShape
15 | import androidx.compose.runtime.*
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.draw.alpha
18 | import androidx.compose.ui.draw.clip
19 | import androidx.compose.ui.draw.shadow
20 | import androidx.compose.ui.graphics.Color
21 | import androidx.compose.ui.layout.onGloballyPositioned
22 | import androidx.compose.ui.platform.LocalDensity
23 | import androidx.compose.ui.semantics.Role
24 | import androidx.compose.ui.unit.Dp
25 | import androidx.compose.ui.unit.dp
26 | import org.jetbrains.compose.ui.tooling.preview.Preview
27 |
28 | /**
29 | * A composable function that creates an iOS-style switch with customizable colors.
30 | *
31 | * @param checked The current checked state of the switch.
32 | * @param onCheckedChange Callback invoked when the switch state changes.
33 | * @param modifier The modifier to be applied to the switch.
34 | * @param enabled Whether the switch is enabled and can be interacted with.
35 | * @param buttonHeight The height of the switch. Default is 40.dp.
36 | * @param innerPadding Padding inside the switch for the thumb. Default is 3.5.dp.
37 | * @param shape The shape of the switch. Default is a rounded corner shape with a radius of 45.dp.
38 | * @param positiveColor The color of the switch when it's in the 'on' state. Default is iOS green color.
39 | * @param negativeColor The color of the switch when it's in the 'off' state. Default is iOS gray color.
40 | * @param disabledPositiveColor The color when checked but disabled.
41 | * @param disabledNegativeColor The color when unchecked and disabled.
42 | * @param interactionSource The MutableInteractionSource representing the stream of interactions with this switch.
43 | */
44 | @Composable
45 | fun ISwitch(
46 | checked: Boolean,
47 | onCheckedChange: ((Boolean) -> Unit)?,
48 | modifier: Modifier = Modifier,
49 | enabled: Boolean = true,
50 | buttonHeight: Dp = 40.dp,
51 | innerPadding: Dp = 3.5.dp,
52 | shape: RoundedCornerShape = RoundedCornerShape(45.dp),
53 | positiveColor: Color = Color(0xFF35C759),
54 | negativeColor: Color = Color(0xFFE9E9EA),
55 | disabledPositiveColor: Color = positiveColor.copy(alpha = 0.38f),
56 | disabledNegativeColor: Color = negativeColor.copy(alpha = 0.38f),
57 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
58 | ) {
59 | var width by remember { mutableStateOf(0.dp) }
60 |
61 | val thumbOffset by remember(checked, width) {
62 | derivedStateOf {
63 | if (checked) width - buttonHeight else 0.dp
64 | }
65 | }
66 |
67 | val animatedThumbOffset by animateDpAsState(
68 | targetValue = thumbOffset,
69 | animationSpec = spring(
70 | dampingRatio = Spring.DampingRatioNoBouncy,
71 | stiffness = Spring.StiffnessMedium
72 | ),
73 | label = "thumb_offset"
74 | )
75 |
76 | val animatedBackgroundColor by animateColorAsState(
77 | targetValue = when {
78 | !enabled && checked -> disabledPositiveColor
79 | !enabled && !checked -> disabledNegativeColor
80 | checked -> positiveColor
81 | else -> negativeColor
82 | },
83 | animationSpec = tween(
84 | durationMillis = 150,
85 | easing = FastOutSlowInEasing
86 | ),
87 | label = "switch_bg_color"
88 | )
89 |
90 | val animatedAlpha by animateFloatAsState(
91 | targetValue = if (enabled) 1f else 0.38f,
92 | animationSpec = tween(durationMillis = 150),
93 | label = "switch_alpha"
94 | )
95 |
96 | val localDensity = LocalDensity.current
97 | Box(
98 | modifier = modifier
99 | .defaultMinSize(
100 | minWidth = buttonHeight * 2,
101 | minHeight = buttonHeight
102 | )
103 | .onGloballyPositioned { coordinates ->
104 | width = with(localDensity) {
105 | coordinates.size.width.toDp()
106 | }
107 | }
108 | .height(buttonHeight)
109 | .clip(shape = shape)
110 | .background(animatedBackgroundColor)
111 | .alpha(animatedAlpha)
112 | .then(
113 | if (onCheckedChange != null) {
114 | Modifier.toggleable(
115 | value = checked,
116 | enabled = enabled,
117 | role = Role.Switch,
118 | interactionSource = interactionSource,
119 | indication = null,
120 | onValueChange = onCheckedChange
121 | )
122 | } else {
123 | Modifier
124 | }
125 | )
126 | ) {
127 | Row {
128 | Box(
129 | modifier = Modifier
130 | .fillMaxHeight()
131 | .width(animatedThumbOffset)
132 | .background(Color.Transparent)
133 | )
134 | Box(
135 | modifier = Modifier
136 | .size(buttonHeight)
137 | .padding(innerPadding)
138 | .shadow(elevation = 5.dp, shape)
139 | .clip(shape = shape)
140 | .background(Color.White)
141 | )
142 | }
143 | }
144 | }
145 |
146 | /**
147 | * @deprecated Use [ISwitch] with checked and onCheckedChange parameters instead.
148 | */
149 | @Deprecated(
150 | message = "This function is deprecated. Use ISwitch with the checked and onCheckedChange parameters instead.",
151 | replaceWith = ReplaceWith(
152 | "ISwitch(checked = switchValue, onCheckedChange = onValueChanged, modifier = modifier, buttonHeight = buttonHeight, innerPadding = innerPadding, shape = shape, positiveColor = positiveColor, negativeColor = negativeColor)",
153 | "dev.muazkadan.switchycompose.ISwitch"
154 | )
155 | )
156 | @Composable
157 | fun ISwitch(
158 | modifier: Modifier = Modifier,
159 | buttonHeight: Dp = 40.dp,
160 | innerPadding: Dp = 3.5.dp,
161 | shape: RoundedCornerShape = RoundedCornerShape(45.dp),
162 | switchValue: Boolean,
163 | positiveColor: Color = Color(0xFF35C759),
164 | negativeColor: Color = Color(0xFFE9E9EA),
165 | onValueChanged: (Boolean) -> Unit,
166 | ) {
167 | ISwitch(
168 | checked = switchValue,
169 | onCheckedChange = onValueChanged,
170 | modifier = modifier,
171 | buttonHeight = buttonHeight,
172 | innerPadding = innerPadding,
173 | shape = shape,
174 | positiveColor = positiveColor,
175 | negativeColor = negativeColor
176 | )
177 | }
178 |
179 | @Preview
180 | @Composable
181 | private fun ISwitchPreview() {
182 | Column(
183 | modifier = Modifier.padding(16.dp),
184 | verticalArrangement = Arrangement.spacedBy(16.dp)
185 | ) {
186 | var checked1 by remember { mutableStateOf(true) }
187 | ISwitch(
188 | checked = checked1,
189 | onCheckedChange = { checked1 = it }
190 | )
191 |
192 | var checked2 by remember { mutableStateOf(false) }
193 | ISwitch(
194 | checked = checked2,
195 | onCheckedChange = { checked2 = it }
196 | )
197 |
198 | // Disabled states
199 | ISwitch(
200 | checked = true,
201 | onCheckedChange = null,
202 | enabled = false
203 | )
204 | }
205 | }
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonMain/kotlin/dev/muazkadan/switchycompose/ColoredSwitch.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.animation.animateColorAsState
4 | import androidx.compose.animation.core.FastOutSlowInEasing
5 | import androidx.compose.animation.core.Spring
6 | import androidx.compose.animation.core.animateDpAsState
7 | import androidx.compose.animation.core.animateFloatAsState
8 | import androidx.compose.animation.core.spring
9 | import androidx.compose.animation.core.tween
10 | import androidx.compose.foundation.background
11 | import androidx.compose.foundation.border
12 | import androidx.compose.foundation.interaction.MutableInteractionSource
13 | import androidx.compose.foundation.layout.*
14 | import androidx.compose.foundation.selection.toggleable
15 | import androidx.compose.foundation.shape.RoundedCornerShape
16 | import androidx.compose.material3.ButtonDefaults
17 | import androidx.compose.material3.MaterialTheme
18 | import androidx.compose.runtime.*
19 | import androidx.compose.ui.Modifier
20 | import androidx.compose.ui.draw.alpha
21 | import androidx.compose.ui.draw.clip
22 | import androidx.compose.ui.graphics.Color
23 | import androidx.compose.ui.layout.onGloballyPositioned
24 | import androidx.compose.ui.platform.LocalDensity
25 | import androidx.compose.ui.semantics.Role
26 | import androidx.compose.ui.unit.dp
27 | import org.jetbrains.compose.ui.tooling.preview.Preview
28 |
29 | /**
30 | * A composable function that creates a switch with customizable colors.
31 | *
32 | * @param checked The current checked state of the switch.
33 | * @param onCheckedChange Callback invoked when the switch state changes.
34 | * @param modifier The modifier to be applied to the switch.
35 | * @param enabled Whether the switch is enabled and can be interacted with.
36 | * @param shape The shape of the switch. Default is a rounded corner shape with a radius of 10.dp.
37 | * @param positiveColor The color of the switch when it's in the 'on' state. Default is green.
38 | * @param negativeColor The color of the switch when it's in the 'off' state. Default is red.
39 | * @param disabledPositiveColor The color when checked but disabled.
40 | * @param disabledNegativeColor The color when unchecked and disabled.
41 | * @param borderColor The color of the switch's border. Default is the primary container color from the current MaterialTheme color scheme.
42 | * @param interactionSource The MutableInteractionSource representing the stream of interactions with this switch.
43 | */
44 | @Composable
45 | fun ColoredSwitch(
46 | checked: Boolean,
47 | onCheckedChange: ((Boolean) -> Unit)?,
48 | modifier: Modifier = Modifier,
49 | enabled: Boolean = true,
50 | shape: RoundedCornerShape = RoundedCornerShape(10.dp),
51 | positiveColor: Color = Color.Green,
52 | negativeColor: Color = Color.Red,
53 | disabledPositiveColor: Color = positiveColor.copy(alpha = 0.38f),
54 | disabledNegativeColor: Color = negativeColor.copy(alpha = 0.38f),
55 | borderColor: Color = MaterialTheme.colorScheme.primaryContainer,
56 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
57 | ) {
58 | var width by remember { mutableStateOf(0.dp) }
59 | var height by remember { mutableStateOf(ButtonDefaults.MinHeight) }
60 |
61 | val thumbOffset by remember(checked, width) {
62 | derivedStateOf {
63 | if (checked) width - (width / 2) else 0.dp
64 | }
65 | }
66 |
67 | val animatedThumbOffset by animateDpAsState(
68 | targetValue = thumbOffset,
69 | animationSpec = spring(
70 | dampingRatio = Spring.DampingRatioNoBouncy,
71 | stiffness = Spring.StiffnessMedium
72 | ),
73 | label = "thumb_offset"
74 | )
75 |
76 | val animatedColor by animateColorAsState(
77 | targetValue = when {
78 | !enabled && checked -> disabledPositiveColor
79 | !enabled && !checked -> disabledNegativeColor
80 | checked -> positiveColor
81 | else -> negativeColor
82 | },
83 | animationSpec = tween(
84 | durationMillis = 150,
85 | easing = FastOutSlowInEasing
86 | ),
87 | label = "switch_color"
88 | )
89 |
90 | val animatedAlpha by animateFloatAsState(
91 | targetValue = if (enabled) 1f else 0.38f,
92 | animationSpec = tween(durationMillis = 150),
93 | label = "switch_alpha"
94 | )
95 |
96 | val localDensity = LocalDensity.current
97 | Box(
98 | modifier = modifier
99 | .defaultMinSize(
100 | minWidth = ButtonDefaults.MinHeight * 2,
101 | minHeight = ButtonDefaults.MinHeight
102 | )
103 | .onGloballyPositioned { coordinates ->
104 | width = with(localDensity) {
105 | coordinates.size.width.toDp()
106 | }
107 | height = with(localDensity) {
108 | coordinates.size.height.toDp()
109 | }
110 | }
111 | .height(height)
112 | .clip(shape = shape)
113 | .border(
114 | width = 1.dp,
115 | color = borderColor.copy(alpha = animatedAlpha),
116 | shape = shape
117 | )
118 | .then(
119 | if (onCheckedChange != null) {
120 | Modifier.toggleable(
121 | value = checked,
122 | enabled = enabled,
123 | role = Role.Switch,
124 | interactionSource = interactionSource,
125 | indication = null,
126 | onValueChange = onCheckedChange
127 | )
128 | } else {
129 | Modifier
130 | }
131 | )
132 | ) {
133 | Row {
134 | Box(
135 | modifier = Modifier
136 | .fillMaxHeight()
137 | .width(animatedThumbOffset)
138 | .background(Color.Transparent)
139 | )
140 | Box(
141 | modifier = Modifier
142 | .height(height)
143 | .width(width / 2)
144 | .clip(shape = shape)
145 | .background(animatedColor)
146 | .alpha(animatedAlpha)
147 | )
148 | }
149 | }
150 | }
151 |
152 | /**
153 | * @deprecated Use [ColoredSwitch] with checked and onCheckedChange parameters instead.
154 | */
155 | @Deprecated(
156 | message = "This function is deprecated. Use ColoredSwitch with the checked and onCheckedChange parameters instead.",
157 | replaceWith = ReplaceWith(
158 | "ColoredSwitch(checked = switchValue, onCheckedChange = onValueChanged, modifier = modifier, shape = shape, positiveColor = positiveColor, negativeColor = negativeColor, borderColor = borderColor)",
159 | "dev.muazkadan.switchycompose.ColoredSwitch"
160 | )
161 | )
162 | @Composable
163 | fun ColoredSwitch(
164 | modifier: Modifier = Modifier,
165 | shape: RoundedCornerShape = RoundedCornerShape(10.dp),
166 | switchValue: Boolean,
167 | positiveColor: Color = Color.Green,
168 | negativeColor: Color = Color.Red,
169 | borderColor: Color = MaterialTheme.colorScheme.primaryContainer,
170 | onValueChanged: (Boolean) -> Unit,
171 | ) {
172 | ColoredSwitch(
173 | checked = switchValue,
174 | onCheckedChange = onValueChanged,
175 | modifier = modifier,
176 | shape = shape,
177 | positiveColor = positiveColor,
178 | negativeColor = negativeColor,
179 | borderColor = borderColor
180 | )
181 | }
182 |
183 | @Preview
184 | @Composable
185 | private fun ColoredSwitchPreview() {
186 | Column(
187 | modifier = Modifier.padding(16.dp),
188 | verticalArrangement = Arrangement.spacedBy(16.dp)
189 | ) {
190 | var checked1 by remember { mutableStateOf(true) }
191 | ColoredSwitch(
192 | checked = checked1,
193 | onCheckedChange = { checked1 = it }
194 | )
195 |
196 | var checked2 by remember { mutableStateOf(false) }
197 | ColoredSwitch(
198 | checked = checked2,
199 | onCheckedChange = { checked2 = it }
200 | )
201 |
202 | // Disabled states
203 | ColoredSwitch(
204 | checked = true,
205 | onCheckedChange = null,
206 | enabled = false
207 | )
208 | }
209 | }
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonMain/kotlin/dev/muazkadan/switchycompose/SquareSwitch.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.animation.animateColorAsState
4 | import androidx.compose.animation.core.FastOutSlowInEasing
5 | import androidx.compose.animation.core.Spring
6 | import androidx.compose.animation.core.animateDpAsState
7 | import androidx.compose.animation.core.animateFloatAsState
8 | import androidx.compose.animation.core.spring
9 | import androidx.compose.animation.core.tween
10 | import androidx.compose.foundation.background
11 | import androidx.compose.foundation.interaction.MutableInteractionSource
12 | import androidx.compose.foundation.layout.*
13 | import androidx.compose.foundation.selection.toggleable
14 | import androidx.compose.foundation.shape.RoundedCornerShape
15 | import androidx.compose.material3.ButtonDefaults
16 | import androidx.compose.material3.MaterialTheme
17 | import androidx.compose.runtime.*
18 | import androidx.compose.ui.Alignment
19 | import androidx.compose.ui.Modifier
20 | import androidx.compose.ui.draw.alpha
21 | import androidx.compose.ui.draw.clip
22 | import androidx.compose.ui.graphics.Color
23 | import androidx.compose.ui.layout.onGloballyPositioned
24 | import androidx.compose.ui.platform.LocalDensity
25 | import androidx.compose.ui.semantics.Role
26 | import androidx.compose.ui.unit.dp
27 | import org.jetbrains.compose.ui.tooling.preview.Preview
28 |
29 | /**
30 | * A composable function that creates a square-style switch with customizable colors.
31 | *
32 | * @param checked The current checked state of the switch.
33 | * @param onCheckedChange Callback invoked when the switch state changes.
34 | * @param modifier The modifier to be applied to the switch.
35 | * @param enabled Whether the switch is enabled and can be interacted with.
36 | * @param shape The shape of the switch. Default is a rounded corner shape with a radius of 2.dp.
37 | * @param squareColor The color of the square. Default is the primary container color from the current MaterialTheme.
38 | * @param containerColor The color of the switch container. Default is the square color with reduced alpha.
39 | * @param disabledSquareColor The color of the square when the switch is disabled.
40 | * @param disabledContainerColor The color of the container when the switch is disabled.
41 | * @param interactionSource The MutableInteractionSource representing the stream of interactions with this switch.
42 | */
43 | @Composable
44 | fun SquareSwitch(
45 | checked: Boolean,
46 | onCheckedChange: ((Boolean) -> Unit)?,
47 | modifier: Modifier = Modifier,
48 | enabled: Boolean = true,
49 | shape: RoundedCornerShape = RoundedCornerShape(2.dp),
50 | squareColor: Color = MaterialTheme.colorScheme.primaryContainer,
51 | containerColor: Color = squareColor.copy(alpha = 0.6f),
52 | disabledSquareColor: Color = squareColor.copy(alpha = 0.38f),
53 | disabledContainerColor: Color = containerColor.copy(alpha = 0.38f),
54 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
55 | ) {
56 | var width by remember { mutableStateOf(0.dp) }
57 | var height by remember { mutableStateOf(ButtonDefaults.MinHeight) }
58 |
59 | val thumbOffset by remember(checked, width) {
60 | derivedStateOf {
61 | if (checked) width - (width / 2) else 0.dp
62 | }
63 | }
64 |
65 | val animatedThumbOffset by animateDpAsState(
66 | targetValue = thumbOffset,
67 | animationSpec = spring(
68 | dampingRatio = Spring.DampingRatioNoBouncy,
69 | stiffness = Spring.StiffnessMedium
70 | ),
71 | label = "thumb_offset"
72 | )
73 |
74 | val animatedSquareColor by animateColorAsState(
75 | targetValue = if (!enabled) disabledSquareColor else squareColor,
76 | animationSpec = tween(
77 | durationMillis = 150,
78 | easing = FastOutSlowInEasing
79 | ),
80 | label = "square_color"
81 | )
82 |
83 | val animatedContainerColor by animateColorAsState(
84 | targetValue = if (!enabled) disabledContainerColor else containerColor,
85 | animationSpec = tween(
86 | durationMillis = 150,
87 | easing = FastOutSlowInEasing
88 | ),
89 | label = "container_color"
90 | )
91 |
92 | val animatedAlpha by animateFloatAsState(
93 | targetValue = if (enabled) 1f else 0.38f,
94 | animationSpec = tween(durationMillis = 150),
95 | label = "switch_alpha"
96 | )
97 |
98 | val localDensity = LocalDensity.current
99 | Box(
100 | modifier = modifier
101 | .defaultMinSize(
102 | minWidth = ButtonDefaults.MinHeight * 2,
103 | minHeight = ButtonDefaults.MinHeight
104 | )
105 | .onGloballyPositioned { coordinates ->
106 | width = with(localDensity) {
107 | coordinates.size.width.toDp()
108 | }
109 | height = with(localDensity) {
110 | coordinates.size.height.toDp()
111 | }
112 | }
113 | .height(height)
114 | .clip(shape = shape)
115 | .then(
116 | if (onCheckedChange != null) {
117 | Modifier.toggleable(
118 | value = checked,
119 | enabled = enabled,
120 | role = Role.Switch,
121 | interactionSource = interactionSource,
122 | indication = null,
123 | onValueChange = onCheckedChange
124 | )
125 | } else {
126 | Modifier
127 | }
128 | )
129 | ) {
130 | Box(
131 | modifier = Modifier
132 | .height(height / 2)
133 | .width(width / 1.3f)
134 | .clip(shape = shape)
135 | .background(animatedContainerColor)
136 | .alpha(animatedAlpha)
137 | .align(Alignment.Center)
138 | )
139 | Row {
140 | Box(
141 | modifier = Modifier
142 | .fillMaxHeight()
143 | .width(animatedThumbOffset)
144 | .background(Color.Transparent)
145 | )
146 | Box(
147 | modifier = Modifier
148 | .height(height)
149 | .width(width / 2)
150 | .clip(shape = shape)
151 | .background(animatedSquareColor)
152 | .alpha(animatedAlpha)
153 | )
154 | }
155 | }
156 | }
157 |
158 | /**
159 | * @deprecated Use [SquareSwitch] with checked and onCheckedChange parameters instead.
160 | */
161 | @Deprecated(
162 | message = "This function is deprecated. Use SquareSwitch with the checked and onCheckedChange parameters instead.",
163 | replaceWith = ReplaceWith(
164 | "SquareSwitch(checked = switchValue, onCheckedChange = onValueChanged, modifier = modifier, shape = shape, squareColor = squareColor, containerColor = containerColor)",
165 | "dev.muazkadan.switchycompose.SquareSwitch"
166 | )
167 | )
168 | @Composable
169 | fun SquareSwitch(
170 | modifier: Modifier = Modifier,
171 | shape: RoundedCornerShape = RoundedCornerShape(2.dp),
172 | switchValue: Boolean,
173 | squareColor: Color = MaterialTheme.colorScheme.primaryContainer,
174 | containerColor: Color = squareColor.copy(alpha = 0.6f),
175 | onValueChanged: (Boolean) -> Unit,
176 | ) {
177 | SquareSwitch(
178 | checked = switchValue,
179 | onCheckedChange = onValueChanged,
180 | modifier = modifier,
181 | shape = shape,
182 | squareColor = squareColor,
183 | containerColor = containerColor
184 | )
185 | }
186 |
187 | @Preview
188 | @Composable
189 | private fun SquareSwitchPreview() {
190 | Column(
191 | modifier = Modifier.padding(16.dp),
192 | verticalArrangement = Arrangement.spacedBy(16.dp)
193 | ) {
194 | var checked1 by remember { mutableStateOf(true) }
195 | SquareSwitch(
196 | checked = checked1,
197 | onCheckedChange = { checked1 = it }
198 | )
199 |
200 | var checked2 by remember { mutableStateOf(false) }
201 | SquareSwitch(
202 | checked = checked2,
203 | onCheckedChange = { checked2 = it }
204 | )
205 |
206 | // Disabled states
207 | SquareSwitch(
208 | checked = true,
209 | onCheckedChange = null,
210 | enabled = false
211 | )
212 | }
213 | }
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonMain/kotlin/dev/muazkadan/switchycompose/IconISwitch.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.animation.animateColorAsState
4 | import androidx.compose.animation.core.FastOutSlowInEasing
5 | import androidx.compose.animation.core.Spring
6 | import androidx.compose.animation.core.animateDpAsState
7 | import androidx.compose.animation.core.animateFloatAsState
8 | import androidx.compose.animation.core.spring
9 | import androidx.compose.animation.core.tween
10 | import androidx.compose.foundation.background
11 | import androidx.compose.foundation.interaction.MutableInteractionSource
12 | import androidx.compose.foundation.layout.*
13 | import androidx.compose.foundation.selection.toggleable
14 | import androidx.compose.foundation.shape.RoundedCornerShape
15 | import androidx.compose.material.icons.Icons
16 | import androidx.compose.material.icons.filled.Close
17 | import androidx.compose.material.icons.filled.Done
18 | import androidx.compose.material3.Icon
19 | import androidx.compose.runtime.*
20 | import androidx.compose.ui.Alignment
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.draw.alpha
23 | import androidx.compose.ui.draw.clip
24 | import androidx.compose.ui.draw.shadow
25 | import androidx.compose.ui.graphics.Color
26 | import androidx.compose.ui.graphics.vector.ImageVector
27 | import androidx.compose.ui.layout.onGloballyPositioned
28 | import androidx.compose.ui.platform.LocalDensity
29 | import androidx.compose.ui.semantics.Role
30 | import androidx.compose.ui.unit.Dp
31 | import androidx.compose.ui.unit.dp
32 | import org.jetbrains.compose.ui.tooling.preview.Preview
33 |
34 | /**
35 | * A composable function that creates an iOS-style switch with customizable colors and icons.
36 | *
37 | * @param checked The current checked state of the switch.
38 | * @param onCheckedChange Callback invoked when the switch state changes.
39 | * @param modifier The modifier to be applied to the switch.
40 | * @param enabled Whether the switch is enabled and can be interacted with.
41 | * @param buttonHeight The height of the switch. Default is 40.dp.
42 | * @param innerPadding Padding inside the switch for the thumb. Default is 3.5.dp.
43 | * @param shape The shape of the switch. Default is a rounded corner shape with a radius of 45.dp.
44 | * @param positiveColor The color of the switch when it's in the 'on' state. Default is iOS green color.
45 | * @param negativeColor The color of the switch when it's in the 'off' state. Default is iOS gray color.
46 | * @param disabledPositiveColor The color when checked but disabled.
47 | * @param disabledNegativeColor The color when unchecked and disabled.
48 | * @param positiveIcon The icon to display in the thumb when the switch is in the 'on' state.
49 | * @param negativeIcon The icon to display in the thumb when the switch is in the 'off' state.
50 | * @param interactionSource The MutableInteractionSource representing the stream of interactions with this switch.
51 | */
52 | @Composable
53 | fun IconISwitch(
54 | checked: Boolean,
55 | onCheckedChange: ((Boolean) -> Unit)?,
56 | modifier: Modifier = Modifier,
57 | enabled: Boolean = true,
58 | buttonHeight: Dp = 40.dp,
59 | innerPadding: Dp = 3.5.dp,
60 | shape: RoundedCornerShape = RoundedCornerShape(45.dp),
61 | positiveColor: Color = Color(0xFF35C759),
62 | negativeColor: Color = Color(0xFFE9E9EA),
63 | disabledPositiveColor: Color = positiveColor.copy(alpha = 0.38f),
64 | disabledNegativeColor: Color = negativeColor.copy(alpha = 0.38f),
65 | positiveIcon: ImageVector = Icons.Default.Done,
66 | negativeIcon: ImageVector = Icons.Default.Close,
67 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
68 | ) {
69 | var width by remember { mutableStateOf(0.dp) }
70 |
71 | val thumbOffset by remember(checked, width) {
72 | derivedStateOf {
73 | if (checked) width - buttonHeight else 0.dp
74 | }
75 | }
76 |
77 | val animatedThumbOffset by animateDpAsState(
78 | targetValue = thumbOffset,
79 | animationSpec = spring(
80 | dampingRatio = Spring.DampingRatioNoBouncy,
81 | stiffness = Spring.StiffnessMedium
82 | ),
83 | label = "thumb_offset"
84 | )
85 |
86 | val animatedBackgroundColor by animateColorAsState(
87 | targetValue = when {
88 | !enabled && checked -> disabledPositiveColor
89 | !enabled && !checked -> disabledNegativeColor
90 | checked -> positiveColor
91 | else -> negativeColor
92 | },
93 | animationSpec = tween(
94 | durationMillis = 150,
95 | easing = FastOutSlowInEasing
96 | ),
97 | label = "switch_bg_color"
98 | )
99 |
100 | val animatedAlpha by animateFloatAsState(
101 | targetValue = if (enabled) 1f else 0.38f,
102 | animationSpec = tween(durationMillis = 150),
103 | label = "switch_alpha"
104 | )
105 |
106 | val localDensity = LocalDensity.current
107 | Box(
108 | modifier = modifier
109 | .defaultMinSize(
110 | minWidth = buttonHeight * 2,
111 | minHeight = buttonHeight
112 | )
113 | .onGloballyPositioned { coordinates ->
114 | width = with(localDensity) {
115 | coordinates.size.width.toDp()
116 | }
117 | }
118 | .height(buttonHeight)
119 | .clip(shape = shape)
120 | .background(animatedBackgroundColor)
121 | .alpha(animatedAlpha)
122 | .then(
123 | if (onCheckedChange != null) {
124 | Modifier.toggleable(
125 | value = checked,
126 | enabled = enabled,
127 | role = Role.Switch,
128 | interactionSource = interactionSource,
129 | indication = null,
130 | onValueChange = onCheckedChange
131 | )
132 | } else {
133 | Modifier
134 | }
135 | )
136 | ) {
137 | Row {
138 | Box(
139 | modifier = Modifier
140 | .fillMaxHeight()
141 | .width(animatedThumbOffset)
142 | .background(Color.Transparent)
143 | )
144 | Box(
145 | modifier = Modifier
146 | .size(buttonHeight)
147 | .padding(innerPadding)
148 | .shadow(elevation = 5.dp, shape)
149 | .clip(shape = shape)
150 | .background(Color.White),
151 | contentAlignment = Alignment.Center
152 | ) {
153 | Icon(
154 | imageVector = if (checked) positiveIcon else negativeIcon,
155 | contentDescription = null,
156 | tint = animatedBackgroundColor
157 | )
158 | }
159 | }
160 | }
161 | }
162 |
163 | /**
164 | * @deprecated Use [IconISwitch] with checked and onCheckedChange parameters instead.
165 | */
166 | @Deprecated(
167 | message = "This function is deprecated. Use IconISwitch with the checked and onCheckedChange parameters instead.",
168 | replaceWith = ReplaceWith(
169 | "IconISwitch(checked = switchValue, onCheckedChange = onValueChanged, modifier = modifier, buttonHeight = buttonHeight, innerPadding = innerPadding, shape = shape, positiveColor = positiveColor, negativeColor = negativeColor, positiveIcon = positiveIcon, negativeIcon = negativeIcon)",
170 | "dev.muazkadan.switchycompose.IconISwitch"
171 | )
172 | )
173 | @Composable
174 | fun IconISwitch(
175 | modifier: Modifier = Modifier,
176 | buttonHeight: Dp = 40.dp,
177 | innerPadding: Dp = 3.5.dp,
178 | shape: RoundedCornerShape = RoundedCornerShape(45.dp),
179 | switchValue: Boolean,
180 | positiveColor: Color = Color(0xFF35C759),
181 | negativeColor: Color = Color(0xFFE9E9EA),
182 | positiveIcon: ImageVector = Icons.Default.Done,
183 | negativeIcon: ImageVector = Icons.Default.Close,
184 | onValueChanged: (Boolean) -> Unit,
185 | ) {
186 | IconISwitch(
187 | checked = switchValue,
188 | onCheckedChange = onValueChanged,
189 | modifier = modifier,
190 | buttonHeight = buttonHeight,
191 | innerPadding = innerPadding,
192 | shape = shape,
193 | positiveColor = positiveColor,
194 | negativeColor = negativeColor,
195 | positiveIcon = positiveIcon,
196 | negativeIcon = negativeIcon
197 | )
198 | }
199 |
200 | @Preview
201 | @Composable
202 | private fun IconISwitchPreview() {
203 | Column(
204 | modifier = Modifier.padding(16.dp),
205 | verticalArrangement = Arrangement.spacedBy(16.dp)
206 | ) {
207 | var checked1 by remember { mutableStateOf(true) }
208 | IconISwitch(
209 | checked = checked1,
210 | onCheckedChange = { checked1 = it }
211 | )
212 |
213 | var checked2 by remember { mutableStateOf(false) }
214 | IconISwitch(
215 | checked = checked2,
216 | onCheckedChange = { checked2 = it }
217 | )
218 |
219 | // Disabled states
220 | IconISwitch(
221 | checked = true,
222 | onCheckedChange = null,
223 | enabled = false
224 | )
225 | }
226 | }
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | #
21 | # Gradle start up script for POSIX generated by Gradle.
22 | #
23 | # Important for running:
24 | #
25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
26 | # noncompliant, but you have some other compliant shell such as ksh or
27 | # bash, then to run this script, type that shell name before the whole
28 | # command line, like:
29 | #
30 | # ksh Gradle
31 | #
32 | # Busybox and similar reduced shells will NOT work, because this script
33 | # requires all of these POSIX shell features:
34 | # * functions;
35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
37 | # * compound commands having a testable exit status, especially «case»;
38 | # * various built-in commands including «command», «set», and «ulimit».
39 | #
40 | # Important for patching:
41 | #
42 | # (2) This script targets any POSIX shell, so it avoids extensions provided
43 | # by Bash, Ksh, etc; in particular arrays are avoided.
44 | #
45 | # The "traditional" practice of packing multiple parameters into a
46 | # space-separated string is a well documented source of bugs and security
47 | # problems, so this is (mostly) avoided, by progressively accumulating
48 | # options in "$@", and eventually passing that to Java.
49 | #
50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
52 | # see the in-line comments for details.
53 | #
54 | # There are tweaks for specific operating systems such as AIX, CygWin,
55 | # Darwin, MinGW, and NonStop.
56 | #
57 | # (3) This script is generated from the Groovy template
58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
59 | # within the Gradle project.
60 | #
61 | # You can find Gradle at https://github.com/gradle/gradle/.
62 | #
63 | ##############################################################################
64 |
65 | # Attempt to set APP_HOME
66 |
67 | # Resolve links: $0 may be a link
68 | app_path=$0
69 |
70 | # Need this for daisy-chained symlinks.
71 | while
72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
73 | [ -h "$app_path" ]
74 | do
75 | ls=$( ls -ld "$app_path" )
76 | link=${ls#*' -> '}
77 | case $link in #(
78 | /*) app_path=$link ;; #(
79 | *) app_path=$APP_HOME$link ;;
80 | esac
81 | done
82 |
83 | # This is normally unused
84 | # shellcheck disable=SC2034
85 | APP_BASE_NAME=${0##*/}
86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
88 |
89 | # Use the maximum available, or set MAX_FD != -1 to use that value.
90 | MAX_FD=maximum
91 |
92 | warn () {
93 | echo "$*"
94 | } >&2
95 |
96 | die () {
97 | echo
98 | echo "$*"
99 | echo
100 | exit 1
101 | } >&2
102 |
103 | # OS specific support (must be 'true' or 'false').
104 | cygwin=false
105 | msys=false
106 | darwin=false
107 | nonstop=false
108 | case "$( uname )" in #(
109 | CYGWIN* ) cygwin=true ;; #(
110 | Darwin* ) darwin=true ;; #(
111 | MSYS* | MINGW* ) msys=true ;; #(
112 | NONSTOP* ) nonstop=true ;;
113 | esac
114 |
115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
116 |
117 |
118 | # Determine the Java command to use to start the JVM.
119 | if [ -n "$JAVA_HOME" ] ; then
120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
121 | # IBM's JDK on AIX uses strange locations for the executables
122 | JAVACMD=$JAVA_HOME/jre/sh/java
123 | else
124 | JAVACMD=$JAVA_HOME/bin/java
125 | fi
126 | if [ ! -x "$JAVACMD" ] ; then
127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
128 |
129 | Please set the JAVA_HOME variable in your environment to match the
130 | location of your Java installation."
131 | fi
132 | else
133 | JAVACMD=java
134 | if ! command -v java >/dev/null 2>&1
135 | then
136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
137 |
138 | Please set the JAVA_HOME variable in your environment to match the
139 | location of your Java installation."
140 | fi
141 | fi
142 |
143 | # Increase the maximum file descriptors if we can.
144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
145 | case $MAX_FD in #(
146 | max*)
147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
148 | # shellcheck disable=SC2039,SC3045
149 | MAX_FD=$( ulimit -H -n ) ||
150 | warn "Could not query maximum file descriptor limit"
151 | esac
152 | case $MAX_FD in #(
153 | '' | soft) :;; #(
154 | *)
155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
156 | # shellcheck disable=SC2039,SC3045
157 | ulimit -n "$MAX_FD" ||
158 | warn "Could not set maximum file descriptor limit to $MAX_FD"
159 | esac
160 | fi
161 |
162 | # Collect all arguments for the java command, stacking in reverse order:
163 | # * args from the command line
164 | # * the main class name
165 | # * -classpath
166 | # * -D...appname settings
167 | # * --module-path (only if needed)
168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
169 |
170 | # For Cygwin or MSYS, switch paths to Windows format before running java
171 | if "$cygwin" || "$msys" ; then
172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
174 |
175 | JAVACMD=$( cygpath --unix "$JAVACMD" )
176 |
177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
178 | for arg do
179 | if
180 | case $arg in #(
181 | -*) false ;; # don't mess with options #(
182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
183 | [ -e "$t" ] ;; #(
184 | *) false ;;
185 | esac
186 | then
187 | arg=$( cygpath --path --ignore --mixed "$arg" )
188 | fi
189 | # Roll the args list around exactly as many times as the number of
190 | # args, so each arg winds up back in the position where it started, but
191 | # possibly modified.
192 | #
193 | # NB: a `for` loop captures its iteration list before it begins, so
194 | # changing the positional parameters here affects neither the number of
195 | # iterations, nor the values presented in `arg`.
196 | shift # remove old arg
197 | set -- "$@" "$arg" # push replacement arg
198 | done
199 | fi
200 |
201 |
202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
204 |
205 | # Collect all arguments for the java command:
206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
207 | # and any embedded shellness will be escaped.
208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
209 | # treated as '${Hostname}' itself on the command line.
210 |
211 | set -- \
212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
213 | -classpath "$CLASSPATH" \
214 | org.gradle.wrapper.GradleWrapperMain \
215 | "$@"
216 |
217 | # Stop when "xargs" is not available.
218 | if ! command -v xargs >/dev/null 2>&1
219 | then
220 | die "xargs is not available"
221 | fi
222 |
223 | # Use "xargs" to parse quoted args.
224 | #
225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
226 | #
227 | # In Bash we could simply go:
228 | #
229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
230 | # set -- "${ARGS[@]}" "$@"
231 | #
232 | # but POSIX shell has neither arrays nor command substitution, so instead we
233 | # post-process each arg (as a line of input to sed) to backslash-escape any
234 | # character that might be a shell metacharacter, then use eval to reverse
235 | # that process (while maintaining the separation between arguments), and wrap
236 | # the whole thing up as a single "set" statement.
237 | #
238 | # This will of course break if any of these variables contains a newline or
239 | # an unmatched quote.
240 | #
241 |
242 | eval "set -- $(
243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
244 | xargs -n1 |
245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
246 | tr '\n' ' '
247 | )" '"$@"'
248 |
249 | exec "$JAVACMD" "$@"
250 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonTest/kotlin/dev/muazkadan/switchycompose/CustomISwitchTest.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.material.icons.Icons
4 | import androidx.compose.material.icons.filled.CheckCircle
5 | import androidx.compose.material.icons.filled.RadioButtonUnchecked
6 | import androidx.compose.material3.Icon
7 | import androidx.compose.material3.MaterialTheme
8 | import androidx.compose.material3.Text
9 | import androidx.compose.runtime.mutableStateOf
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.platform.testTag
12 | import androidx.compose.ui.test.ExperimentalTestApi
13 | import androidx.compose.ui.test.assertIsDisplayed
14 | import androidx.compose.ui.test.assertIsOff
15 | import androidx.compose.ui.test.assertIsOn
16 | import androidx.compose.ui.test.assertIsNotEnabled
17 | import androidx.compose.ui.test.onNodeWithTag
18 | import androidx.compose.ui.test.performClick
19 | import androidx.compose.ui.test.runComposeUiTest
20 | import kotlin.test.Test
21 | import kotlin.test.assertEquals
22 |
23 | @OptIn(ExperimentalTestApi::class)
24 | class CustomISwitchTest {
25 |
26 | private val switchTag = "customISwitch"
27 |
28 | @Test
29 | fun testInitialStateUnchecked() = runComposeUiTest {
30 | setContent {
31 | MaterialTheme {
32 | CustomISwitch(
33 | modifier = Modifier.testTag(switchTag),
34 | checked = false,
35 | onCheckedChange = { },
36 | positiveContent = {
37 | Text("ON")
38 | },
39 | negativeContent = {
40 | Text("OFF")
41 | }
42 | )
43 | }
44 | }
45 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOff()
46 | }
47 |
48 | @Test
49 | fun testInitialStateChecked() = runComposeUiTest {
50 | setContent {
51 | MaterialTheme {
52 | CustomISwitch(
53 | modifier = Modifier.testTag(switchTag),
54 | checked = true,
55 | onCheckedChange = { },
56 | positiveContent = {
57 | Text("ON")
58 | },
59 | negativeContent = {
60 | Text("OFF")
61 | }
62 | )
63 | }
64 | }
65 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOn()
66 | }
67 |
68 | @Test
69 | fun testToggle() = runComposeUiTest {
70 | val checkedState = mutableStateOf(false)
71 | setContent {
72 | MaterialTheme {
73 | CustomISwitch(
74 | modifier = Modifier.testTag(switchTag),
75 | checked = checkedState.value,
76 | onCheckedChange = { checkedState.value = it },
77 | positiveContent = {
78 | Text("ON")
79 | },
80 | negativeContent = {
81 | Text("OFF")
82 | }
83 | )
84 | }
85 | }
86 |
87 | // Initial state: Off
88 | onNodeWithTag(switchTag).assertIsOff()
89 | assertEquals(false, checkedState.value)
90 |
91 | // Click to turn On
92 | onNodeWithTag(switchTag).performClick()
93 | onNodeWithTag(switchTag).assertIsOn()
94 | assertEquals(true, checkedState.value)
95 |
96 | // Click to turn Off
97 | onNodeWithTag(switchTag).performClick()
98 | onNodeWithTag(switchTag).assertIsOff()
99 | assertEquals(false, checkedState.value)
100 | }
101 |
102 | @Test
103 | fun testOnCheckedChangeCallback() = runComposeUiTest {
104 | var callbackValue: Boolean? = null
105 | val checkedState = mutableStateOf(false)
106 | setContent {
107 | MaterialTheme {
108 | CustomISwitch(
109 | modifier = Modifier.testTag(switchTag),
110 | checked = checkedState.value,
111 | onCheckedChange = {
112 | callbackValue = it
113 | checkedState.value = it
114 | },
115 | positiveContent = {
116 | Text("ON")
117 | },
118 | negativeContent = {
119 | Text("OFF")
120 | }
121 | )
122 | }
123 | }
124 |
125 | // Click to toggle
126 | onNodeWithTag(switchTag).performClick()
127 | assertEquals(true, callbackValue)
128 |
129 | // Click again to toggle back
130 | onNodeWithTag(switchTag).performClick()
131 | assertEquals(false, callbackValue)
132 | }
133 |
134 | @Test
135 | fun testDisabledStateUnchecked() = runComposeUiTest {
136 | val checkedState = mutableStateOf(false)
137 | var callbackCalled = false
138 | setContent {
139 | MaterialTheme {
140 | CustomISwitch(
141 | modifier = Modifier.testTag(switchTag),
142 | checked = checkedState.value,
143 | onCheckedChange = {
144 | checkedState.value = it
145 | callbackCalled = true
146 | },
147 | enabled = false,
148 | positiveContent = {
149 | Text("ON")
150 | },
151 | negativeContent = {
152 | Text("OFF")
153 | }
154 | )
155 | }
156 | }
157 |
158 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOff()
159 |
160 | // Attempt to click, should not change state or call callback
161 | onNodeWithTag(switchTag).performClick()
162 | onNodeWithTag(switchTag).assertIsOff()
163 | assertEquals(false, checkedState.value)
164 | assertEquals(false, callbackCalled)
165 | }
166 |
167 | @Test
168 | fun testDisabledStateChecked() = runComposeUiTest {
169 | val checkedState = mutableStateOf(true)
170 | var callbackCalled = false
171 | setContent {
172 | MaterialTheme {
173 | CustomISwitch(
174 | modifier = Modifier.testTag(switchTag),
175 | checked = checkedState.value,
176 | onCheckedChange = {
177 | checkedState.value = it
178 | callbackCalled = true
179 | },
180 | enabled = false,
181 | positiveContent = {
182 | Text("ON")
183 | },
184 | negativeContent = {
185 | Text("OFF")
186 | }
187 | )
188 | }
189 | }
190 |
191 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOn()
192 |
193 | // Attempt to click, should not change state or call callback
194 | onNodeWithTag(switchTag).performClick()
195 | onNodeWithTag(switchTag).assertIsOn()
196 | assertEquals(true, checkedState.value)
197 | assertEquals(false, callbackCalled)
198 | }
199 |
200 | @Test
201 | fun testNullOnCheckedChangeCallback() = runComposeUiTest {
202 | setContent {
203 | MaterialTheme {
204 | CustomISwitch(
205 | modifier = Modifier.testTag(switchTag),
206 | checked = false,
207 | onCheckedChange = null,
208 | positiveContent = {
209 | Text("ON")
210 | },
211 | negativeContent = {
212 | Text("OFF")
213 | }
214 | )
215 | }
216 | }
217 |
218 | // Should be displayed, but we don't use assertIsOff() because
219 | // without onCheckedChange, the node doesn't have toggleable semantics
220 | onNodeWithTag(switchTag).assertIsDisplayed()
221 |
222 | // Attempt to click - this should effectively be a no-op
223 | // since the switch has no callback to handle the click
224 | onNodeWithTag(switchTag).performClick()
225 |
226 | // The switch should still be displayed
227 | // Note: We can't use assertIsOff() here because there are no toggleable semantics
228 | // when onCheckedChange is null
229 | onNodeWithTag(switchTag).assertIsDisplayed()
230 | }
231 |
232 | @Test
233 | fun testCustomIconContent() = runComposeUiTest {
234 | val checkedState = mutableStateOf(false)
235 | setContent {
236 | MaterialTheme {
237 | CustomISwitch(
238 | modifier = Modifier.testTag(switchTag),
239 | checked = checkedState.value,
240 | onCheckedChange = { checkedState.value = it },
241 | positiveContent = {
242 | Icon(Icons.Default.CheckCircle, contentDescription = null)
243 | },
244 | negativeContent = {
245 | Icon(Icons.Default.RadioButtonUnchecked, contentDescription = null)
246 | }
247 | )
248 | }
249 | }
250 |
251 | // Initial state: Off
252 | onNodeWithTag(switchTag).assertIsOff()
253 | assertEquals(false, checkedState.value)
254 |
255 | // Click to turn On
256 | onNodeWithTag(switchTag).performClick()
257 | onNodeWithTag(switchTag).assertIsOn()
258 | assertEquals(true, checkedState.value)
259 | }
260 | }
261 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonMain/kotlin/dev/muazkadan/switchycompose/CustomISwitch.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.animation.animateColorAsState
4 | import androidx.compose.animation.core.FastOutSlowInEasing
5 | import androidx.compose.animation.core.Spring
6 | import androidx.compose.animation.core.animateDpAsState
7 | import androidx.compose.animation.core.animateFloatAsState
8 | import androidx.compose.animation.core.spring
9 | import androidx.compose.animation.core.tween
10 | import androidx.compose.foundation.background
11 | import androidx.compose.foundation.interaction.MutableInteractionSource
12 | import androidx.compose.foundation.layout.*
13 | import androidx.compose.foundation.selection.toggleable
14 | import androidx.compose.foundation.shape.RoundedCornerShape
15 | import androidx.compose.material.icons.Icons
16 | import androidx.compose.material.icons.filled.Done
17 | import androidx.compose.material3.Icon
18 | import androidx.compose.runtime.*
19 | import androidx.compose.ui.Alignment
20 | import androidx.compose.ui.Modifier
21 | import androidx.compose.ui.draw.alpha
22 | import androidx.compose.ui.draw.clip
23 | import androidx.compose.ui.draw.shadow
24 | import androidx.compose.ui.graphics.Color
25 | import androidx.compose.ui.layout.onGloballyPositioned
26 | import androidx.compose.ui.platform.LocalDensity
27 | import androidx.compose.ui.semantics.Role
28 | import androidx.compose.ui.unit.Dp
29 | import androidx.compose.ui.unit.dp
30 | import org.jetbrains.compose.ui.tooling.preview.Preview
31 |
32 | /**
33 | * A composable function that creates a customizable iOS-style switch with content inside the thumb.
34 | *
35 | * @param checked The current checked state of the switch.
36 | * @param onCheckedChange Callback invoked when the switch state changes.
37 | * @param modifier The modifier to be applied to the switch.
38 | * @param enabled Whether the switch is enabled and can be interacted with.
39 | * @param buttonHeight The height of the switch. Default is 40.dp.
40 | * @param innerPadding Padding inside the switch for the thumb. Default is 3.5.dp.
41 | * @param shape The shape of the switch. Default is a rounded corner shape with a radius of 45.dp.
42 | * @param positiveColor The color of the switch when it's in the 'on' state. Default is iOS green color.
43 | * @param negativeColor The color of the switch when it's in the 'off' state. Default is iOS gray color.
44 | * @param disabledPositiveColor The color when checked but disabled.
45 | * @param disabledNegativeColor The color when unchecked and disabled.
46 | * @param positiveContent The composable to be displayed inside the thumb when the switch is checked.
47 | * @param negativeContent The composable to be displayed inside the thumb when the switch is unchecked.
48 | * @param interactionSource The MutableInteractionSource representing the stream of interactions with this switch.
49 | */
50 | @Composable
51 | fun CustomISwitch(
52 | checked: Boolean,
53 | onCheckedChange: ((Boolean) -> Unit)?,
54 | modifier: Modifier = Modifier,
55 | enabled: Boolean = true,
56 | buttonHeight: Dp = 40.dp,
57 | innerPadding: Dp = 3.5.dp,
58 | shape: RoundedCornerShape = RoundedCornerShape(45.dp),
59 | positiveColor: Color = Color(0xFF35C759),
60 | negativeColor: Color = Color(0xFFE9E9EA),
61 | disabledPositiveColor: Color = positiveColor.copy(alpha = 0.38f),
62 | disabledNegativeColor: Color = negativeColor.copy(alpha = 0.38f),
63 | positiveContent: @Composable BoxScope.() -> Unit,
64 | negativeContent: @Composable BoxScope.() -> Unit,
65 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
66 | ) {
67 | var width by remember { mutableStateOf(0.dp) }
68 |
69 | val thumbOffset by remember(checked, width) {
70 | derivedStateOf {
71 | if (checked) width - buttonHeight else 0.dp
72 | }
73 | }
74 |
75 |
76 | val animatedThumbOffset by animateDpAsState(
77 | targetValue = thumbOffset,
78 | animationSpec = spring(
79 | dampingRatio = Spring.DampingRatioNoBouncy,
80 | stiffness = Spring.StiffnessMedium
81 | ),
82 | label = "thumb_offset"
83 | )
84 |
85 | val animatedBackgroundColor by animateColorAsState(
86 | targetValue = when {
87 | !enabled && checked -> disabledPositiveColor
88 | !enabled && !checked -> disabledNegativeColor
89 | checked -> positiveColor
90 | else -> negativeColor
91 | },
92 | animationSpec = tween(
93 | durationMillis = 150,
94 | easing = FastOutSlowInEasing
95 | ),
96 | label = "switch_bg_color"
97 | )
98 |
99 | val animatedAlpha by animateFloatAsState(
100 | targetValue = if (enabled) 1f else 0.38f,
101 | animationSpec = tween(durationMillis = 150),
102 | label = "switch_alpha"
103 | )
104 |
105 | val localDensity = LocalDensity.current
106 | Box(
107 | modifier = modifier
108 | .defaultMinSize(
109 | minWidth = buttonHeight * 2,
110 | minHeight = buttonHeight
111 | )
112 | .onGloballyPositioned { coordinates ->
113 | width = with(localDensity) {
114 | coordinates.size.width.toDp()
115 | }
116 | }
117 | .height(buttonHeight)
118 | .clip(shape = shape)
119 | .background(animatedBackgroundColor)
120 | .alpha(animatedAlpha)
121 | .then(
122 | if (onCheckedChange != null) {
123 | Modifier.toggleable(
124 | value = checked,
125 | enabled = enabled,
126 | role = Role.Switch,
127 | interactionSource = interactionSource,
128 | indication = null,
129 | onValueChange = onCheckedChange
130 | )
131 | } else {
132 | Modifier
133 | }
134 | )
135 | ) {
136 | Row {
137 | Box(
138 | modifier = Modifier
139 | .fillMaxHeight()
140 | .width(animatedThumbOffset)
141 | .background(Color.Transparent)
142 | )
143 | Box(
144 | modifier = Modifier
145 | .size(buttonHeight)
146 | .padding(innerPadding)
147 | .shadow(elevation = 5.dp, shape)
148 | .clip(shape = shape)
149 | .background(Color.White),
150 | contentAlignment = Alignment.Center
151 | ) {
152 | if (checked)
153 | positiveContent()
154 | else
155 | negativeContent()
156 | }
157 | }
158 | }
159 | }
160 |
161 | /**
162 | * @deprecated Use [CustomISwitch] with checked and onCheckedChange parameters instead.
163 | */
164 | @Deprecated(
165 | message = "This function is deprecated. Use CustomISwitch with the checked and onCheckedChange parameters instead.",
166 | replaceWith = ReplaceWith(
167 | "CustomISwitch(checked = switchValue, onCheckedChange = onValueChanged, modifier = modifier, buttonHeight = buttonHeight, innerPadding = innerPadding, shape = shape, positiveColor = positiveColor, negativeColor = negativeColor, positiveContent = positiveContent, negativeContent = negativeContent)",
168 | "dev.muazkadan.switchycompose.CustomISwitch"
169 | )
170 | )
171 | @Composable
172 | fun CustomISwitch(
173 | modifier: Modifier = Modifier,
174 | buttonHeight: Dp = 40.dp,
175 | innerPadding: Dp = 3.5.dp,
176 | shape: RoundedCornerShape = RoundedCornerShape(45.dp),
177 | switchValue: Boolean,
178 | positiveColor: Color = Color(0xFF35C759),
179 | negativeColor: Color = Color(0xFFE9E9EA),
180 | positiveContent: @Composable BoxScope.() -> Unit,
181 | negativeContent: @Composable BoxScope.() -> Unit,
182 | onValueChanged: (Boolean) -> Unit,
183 | ) {
184 | CustomISwitch(
185 | checked = switchValue,
186 | onCheckedChange = onValueChanged,
187 | modifier = modifier,
188 | buttonHeight = buttonHeight,
189 | innerPadding = innerPadding,
190 | shape = shape,
191 | positiveColor = positiveColor,
192 | negativeColor = negativeColor,
193 | positiveContent = positiveContent,
194 | negativeContent = negativeContent
195 | )
196 | }
197 |
198 | @Preview
199 | @Composable
200 | private fun CustomISwitchPreview() {
201 | Column(
202 | modifier = Modifier.padding(16.dp),
203 | verticalArrangement = Arrangement.spacedBy(16.dp)
204 | ) {
205 | var checked1 by remember { mutableStateOf(true) }
206 | CustomISwitch(
207 | checked = checked1,
208 | onCheckedChange = { checked1 = it },
209 | positiveContent = {
210 | Icon(
211 | imageVector = Icons.Default.Done,
212 | contentDescription = null
213 | )
214 | },
215 | negativeContent = {}
216 | )
217 |
218 | var checked2 by remember { mutableStateOf(false) }
219 | CustomISwitch(
220 | checked = checked2,
221 | onCheckedChange = { checked2 = it },
222 | positiveContent = {
223 | Icon(
224 | imageVector = Icons.Default.Done,
225 | contentDescription = null
226 | )
227 | },
228 | negativeContent = {}
229 | )
230 |
231 | // Disabled states
232 | CustomISwitch(
233 | checked = true,
234 | onCheckedChange = null,
235 | enabled = false,
236 | positiveContent = {
237 | Icon(
238 | imageVector = Icons.Default.Done,
239 | contentDescription = null
240 | )
241 | },
242 | negativeContent = {}
243 | )
244 | }
245 | }
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonTest/kotlin/dev/muazkadan/switchycompose/CustomSwitchTest.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.material.icons.Icons
4 | import androidx.compose.material.icons.filled.Star
5 | import androidx.compose.material.icons.filled.StarBorder
6 | import androidx.compose.material3.Icon
7 | import androidx.compose.material3.MaterialTheme
8 | import androidx.compose.material3.Text
9 | import androidx.compose.runtime.mutableStateOf
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.platform.testTag
12 | import androidx.compose.ui.test.ExperimentalTestApi
13 | import androidx.compose.ui.test.assertIsDisplayed
14 | import androidx.compose.ui.test.assertIsOff
15 | import androidx.compose.ui.test.assertIsOn
16 | import androidx.compose.ui.test.assertIsNotEnabled
17 | import androidx.compose.ui.test.onNodeWithTag
18 | import androidx.compose.ui.test.performClick
19 | import androidx.compose.ui.test.runComposeUiTest
20 | import kotlin.test.Test
21 | import kotlin.test.assertEquals
22 |
23 | @OptIn(ExperimentalTestApi::class)
24 | class CustomSwitchTest {
25 |
26 | private val switchTag = "customSwitch"
27 |
28 | @Test
29 | fun testInitialStateUnchecked() = runComposeUiTest {
30 | setContent {
31 | MaterialTheme {
32 | CustomSwitch(
33 | modifier = Modifier.testTag(switchTag),
34 | checked = false,
35 | onCheckedChange = { },
36 | positiveContent = {
37 | Text("YES")
38 | },
39 | negativeContent = {
40 | Text("NO")
41 | }
42 | )
43 | }
44 | }
45 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOff()
46 | }
47 |
48 | @Test
49 | fun testInitialStateChecked() = runComposeUiTest {
50 | setContent {
51 | MaterialTheme {
52 | CustomSwitch(
53 | modifier = Modifier.testTag(switchTag),
54 | checked = true,
55 | onCheckedChange = { },
56 | positiveContent = {
57 | Text("YES")
58 | },
59 | negativeContent = {
60 | Text("NO")
61 | }
62 | )
63 | }
64 | }
65 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsOn()
66 | }
67 |
68 | @Test
69 | fun testToggle() = runComposeUiTest {
70 | val checkedState = mutableStateOf(false)
71 | setContent {
72 | MaterialTheme {
73 | CustomSwitch(
74 | modifier = Modifier.testTag(switchTag),
75 | checked = checkedState.value,
76 | onCheckedChange = { checkedState.value = it },
77 | positiveContent = {
78 | Text("YES")
79 | },
80 | negativeContent = {
81 | Text("NO")
82 | }
83 | )
84 | }
85 | }
86 |
87 | // Initial state: Off
88 | onNodeWithTag(switchTag).assertIsOff()
89 | assertEquals(false, checkedState.value)
90 |
91 | // Click to turn On
92 | onNodeWithTag(switchTag).performClick()
93 | onNodeWithTag(switchTag).assertIsOn()
94 | assertEquals(true, checkedState.value)
95 |
96 | // Click to turn Off
97 | onNodeWithTag(switchTag).performClick()
98 | onNodeWithTag(switchTag).assertIsOff()
99 | assertEquals(false, checkedState.value)
100 | }
101 |
102 | @Test
103 | fun testOnCheckedChangeCallback() = runComposeUiTest {
104 | var callbackValue: Boolean? = null
105 | val checkedState = mutableStateOf(false)
106 | setContent {
107 | MaterialTheme {
108 | CustomSwitch(
109 | modifier = Modifier.testTag(switchTag),
110 | checked = checkedState.value,
111 | onCheckedChange = {
112 | callbackValue = it
113 | checkedState.value = it
114 | },
115 | positiveContent = {
116 | Text("YES")
117 | },
118 | negativeContent = {
119 | Text("NO")
120 | }
121 | )
122 | }
123 | }
124 |
125 | // Click to toggle
126 | onNodeWithTag(switchTag).performClick()
127 | assertEquals(true, callbackValue)
128 |
129 | // Click again to toggle back
130 | onNodeWithTag(switchTag).performClick()
131 | assertEquals(false, callbackValue)
132 | }
133 |
134 | @Test
135 | fun testDisabledStateUnchecked() = runComposeUiTest {
136 | val checkedState = mutableStateOf(false)
137 | var callbackCalled = false
138 | setContent {
139 | MaterialTheme {
140 | CustomSwitch(
141 | modifier = Modifier.testTag(switchTag),
142 | checked = checkedState.value,
143 | onCheckedChange = {
144 | checkedState.value = it
145 | callbackCalled = true
146 | },
147 | enabled = false,
148 | positiveContent = {
149 | Text("YES")
150 | },
151 | negativeContent = {
152 | Text("NO")
153 | }
154 | )
155 | }
156 | }
157 |
158 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOff()
159 |
160 | // Attempt to click, should not change state or call callback
161 | onNodeWithTag(switchTag).performClick()
162 | onNodeWithTag(switchTag).assertIsOff()
163 | assertEquals(false, checkedState.value)
164 | assertEquals(false, callbackCalled)
165 | }
166 |
167 | @Test
168 | fun testDisabledStateChecked() = runComposeUiTest {
169 | val checkedState = mutableStateOf(true)
170 | var callbackCalled = false
171 | setContent {
172 | MaterialTheme {
173 | CustomSwitch(
174 | modifier = Modifier.testTag(switchTag),
175 | checked = checkedState.value,
176 | onCheckedChange = {
177 | checkedState.value = it
178 | callbackCalled = true
179 | },
180 | enabled = false,
181 | positiveContent = {
182 | Text("YES")
183 | },
184 | negativeContent = {
185 | Text("NO")
186 | }
187 | )
188 | }
189 | }
190 |
191 | onNodeWithTag(switchTag).assertIsDisplayed().assertIsNotEnabled().assertIsOn()
192 |
193 | // Attempt to click, should not change state or call callback
194 | onNodeWithTag(switchTag).performClick()
195 | onNodeWithTag(switchTag).assertIsOn()
196 | assertEquals(true, checkedState.value)
197 | assertEquals(false, callbackCalled)
198 | }
199 |
200 | @Test
201 | fun testNullOnCheckedChangeCallback() = runComposeUiTest {
202 | setContent {
203 | MaterialTheme {
204 | CustomSwitch(
205 | modifier = Modifier.testTag(switchTag),
206 | checked = false,
207 | onCheckedChange = null,
208 | positiveContent = {
209 | Text("YES")
210 | },
211 | negativeContent = {
212 | Text("NO")
213 | }
214 | )
215 | }
216 | }
217 |
218 | // Should be displayed
219 | // Note: We can't use assertIsOff() because the component doesn't have toggleable
220 | // semantics when onCheckedChange is null
221 | onNodeWithTag(switchTag).assertIsDisplayed()
222 |
223 | // Click should be a no-op since callback is null
224 | onNodeWithTag(switchTag).performClick()
225 |
226 | // Should still be displayed after click
227 | onNodeWithTag(switchTag).assertIsDisplayed()
228 | }
229 |
230 | @Test
231 | fun testCustomIconContent() = runComposeUiTest {
232 | val checkedState = mutableStateOf(false)
233 | setContent {
234 | MaterialTheme {
235 | CustomSwitch(
236 | modifier = Modifier.testTag(switchTag),
237 | checked = checkedState.value,
238 | onCheckedChange = { checkedState.value = it },
239 | positiveContent = {
240 | Icon(Icons.Default.Star, contentDescription = null)
241 | },
242 | negativeContent = {
243 | Icon(Icons.Default.StarBorder, contentDescription = null)
244 | }
245 | )
246 | }
247 | }
248 |
249 | // Initial state: Off
250 | onNodeWithTag(switchTag).assertIsOff()
251 | assertEquals(false, checkedState.value)
252 |
253 | // Click to turn On
254 | onNodeWithTag(switchTag).performClick()
255 | onNodeWithTag(switchTag).assertIsOn()
256 | assertEquals(true, checkedState.value)
257 | }
258 |
259 | @Test
260 | fun testMixedContentTypes() = runComposeUiTest {
261 | val checkedState = mutableStateOf(false)
262 | setContent {
263 | MaterialTheme {
264 | CustomSwitch(
265 | modifier = Modifier.testTag(switchTag),
266 | checked = checkedState.value,
267 | onCheckedChange = { checkedState.value = it },
268 | positiveContent = {
269 | Icon(Icons.Default.Star, contentDescription = null)
270 | },
271 | negativeContent = {
272 | Text("OFF")
273 | }
274 | )
275 | }
276 | }
277 |
278 | // Initial state: Off
279 | onNodeWithTag(switchTag).assertIsOff()
280 | assertEquals(false, checkedState.value)
281 |
282 | // Click to turn On
283 | onNodeWithTag(switchTag).performClick()
284 | onNodeWithTag(switchTag).assertIsOn()
285 | assertEquals(true, checkedState.value)
286 | }
287 | }
288 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonMain/kotlin/dev/muazkadan/switchycompose/TextSwitch.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.animation.animateColorAsState
4 | import androidx.compose.animation.core.FastOutSlowInEasing
5 | import androidx.compose.animation.core.Spring
6 | import androidx.compose.animation.core.animateDpAsState
7 | import androidx.compose.animation.core.animateFloatAsState
8 | import androidx.compose.animation.core.spring
9 | import androidx.compose.animation.core.tween
10 | import androidx.compose.foundation.background
11 | import androidx.compose.foundation.border
12 | import androidx.compose.foundation.interaction.MutableInteractionSource
13 | import androidx.compose.foundation.layout.*
14 | import androidx.compose.foundation.selection.toggleable
15 | import androidx.compose.foundation.shape.RoundedCornerShape
16 | import androidx.compose.material3.ButtonDefaults
17 | import androidx.compose.material3.MaterialTheme
18 | import androidx.compose.material3.Text
19 | import androidx.compose.runtime.*
20 | import androidx.compose.ui.Alignment
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.draw.alpha
23 | import androidx.compose.ui.draw.clip
24 | import androidx.compose.ui.graphics.Color
25 | import androidx.compose.ui.layout.onGloballyPositioned
26 | import androidx.compose.ui.platform.LocalDensity
27 | import androidx.compose.ui.semantics.Role
28 | import androidx.compose.ui.text.style.TextAlign
29 | import androidx.compose.ui.unit.dp
30 | import org.jetbrains.compose.ui.tooling.preview.Preview
31 |
32 | /**
33 | * A composable function that creates a switch with text labels for both states.
34 | *
35 | * @param checked The current checked state of the switch.
36 | * @param onCheckedChange Callback invoked when the switch state changes.
37 | * @param modifier The modifier to be applied to the switch.
38 | * @param enabled Whether the switch is enabled and can be interacted with.
39 | * @param shape The shape of the switch. Default is a rounded corner shape with a radius of 10.dp.
40 | * @param color The color of the switch's active background. Default is the primary container color from the current MaterialTheme.
41 | * @param borderColor The color of the switch's border. Default is the primary container color from the current MaterialTheme.
42 | * @param textColor The color of the text. Default is the onPrimaryContainer color from the current MaterialTheme color scheme.
43 | * @param disabledColor The color of the active background when the switch is disabled.
44 | * @param disabledBorderColor The color of the border when the switch is disabled.
45 | * @param disabledTextColor The color of the text when the switch is disabled.
46 | * @param positiveText The text to display on the 'on' side of the switch. Default is "Yes".
47 | * @param negativeText The text to display on the 'off' side of the switch. Default is "No".
48 | * @param interactionSource The MutableInteractionSource representing the stream of interactions with this switch.
49 | */
50 | @Composable
51 | fun TextSwitch(
52 | checked: Boolean,
53 | onCheckedChange: ((Boolean) -> Unit)?,
54 | modifier: Modifier = Modifier,
55 | enabled: Boolean = true,
56 | shape: RoundedCornerShape = RoundedCornerShape(10.dp),
57 | color: Color = MaterialTheme.colorScheme.primaryContainer,
58 | borderColor: Color = MaterialTheme.colorScheme.primaryContainer,
59 | textColor: Color = MaterialTheme.colorScheme.onPrimaryContainer,
60 | disabledColor: Color = color.copy(alpha = 0.38f),
61 | disabledBorderColor: Color = borderColor.copy(alpha = 0.38f),
62 | disabledTextColor: Color = textColor.copy(alpha = 0.38f),
63 | positiveText: String = "Yes",
64 | negativeText: String = "No",
65 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
66 | ) {
67 | var width by remember { mutableStateOf(0.dp) }
68 | var height by remember { mutableStateOf(ButtonDefaults.MinHeight) }
69 |
70 | val thumbOffset by remember(checked, width) {
71 | derivedStateOf {
72 | if (checked) width - (width / 2) else 0.dp
73 | }
74 | }
75 |
76 | val animatedThumbOffset by animateDpAsState(
77 | targetValue = thumbOffset,
78 | animationSpec = spring(
79 | dampingRatio = Spring.DampingRatioNoBouncy,
80 | stiffness = Spring.StiffnessMedium
81 | ),
82 | label = "thumb_offset"
83 | )
84 |
85 | val animatedColor by animateColorAsState(
86 | targetValue = if (!enabled) disabledColor else color,
87 | animationSpec = tween(
88 | durationMillis = 150,
89 | easing = FastOutSlowInEasing
90 | ),
91 | label = "color"
92 | )
93 |
94 | val animatedBorderColor by animateColorAsState(
95 | targetValue = if (!enabled) disabledBorderColor else borderColor,
96 | animationSpec = tween(
97 | durationMillis = 150,
98 | easing = FastOutSlowInEasing
99 | ),
100 | label = "border_color"
101 | )
102 |
103 | val animatedTextColor by animateColorAsState(
104 | targetValue = if (!enabled) disabledTextColor else textColor,
105 | animationSpec = tween(
106 | durationMillis = 150,
107 | easing = FastOutSlowInEasing
108 | ),
109 | label = "text_color"
110 | )
111 |
112 | val animatedAlpha by animateFloatAsState(
113 | targetValue = if (enabled) 1f else 0.38f,
114 | animationSpec = tween(durationMillis = 150),
115 | label = "switch_alpha"
116 | )
117 |
118 | val localDensity = LocalDensity.current
119 | Box(
120 | modifier = modifier
121 | .defaultMinSize(
122 | minWidth = ButtonDefaults.MinHeight * 2,
123 | minHeight = ButtonDefaults.MinHeight
124 | )
125 | .onGloballyPositioned { coordinates ->
126 | width = with(localDensity) {
127 | coordinates.size.width.toDp()
128 | }
129 | height = with(localDensity) {
130 | coordinates.size.height.toDp()
131 | }
132 | }
133 | .height(height)
134 | .clip(shape = shape)
135 | .border(
136 | width = 1.dp,
137 | color = animatedBorderColor,
138 | shape = shape
139 | )
140 | .then(
141 | if (onCheckedChange != null) {
142 | Modifier.toggleable(
143 | value = checked,
144 | enabled = enabled,
145 | role = Role.Switch,
146 | interactionSource = interactionSource,
147 | indication = null,
148 | onValueChange = onCheckedChange
149 | )
150 | } else {
151 | Modifier
152 | }
153 | )
154 | ) {
155 | Row(
156 | modifier = Modifier
157 | .fillMaxHeight()
158 | ) {
159 | Box(
160 | modifier = Modifier
161 | .fillMaxHeight()
162 | .width(animatedThumbOffset)
163 | .background(Color.Transparent)
164 | )
165 | Box(
166 | modifier = Modifier
167 | .height(height)
168 | .width(width / 2)
169 | .clip(shape = shape)
170 | .background(animatedColor)
171 | .alpha(animatedAlpha)
172 | )
173 | }
174 | Row(
175 | modifier = Modifier
176 | .fillMaxHeight(),
177 | verticalAlignment = Alignment.CenterVertically
178 | ) {
179 | Text(
180 | modifier = Modifier.weight(1f),
181 | text = negativeText,
182 | textAlign = TextAlign.Center,
183 | color = animatedTextColor
184 | )
185 | Text(
186 | modifier = Modifier.weight(1f),
187 | text = positiveText,
188 | textAlign = TextAlign.Center,
189 | color = animatedTextColor
190 | )
191 | }
192 | }
193 | }
194 |
195 | /**
196 | * @deprecated Use [TextSwitch] with checked and onCheckedChange parameters instead.
197 | */
198 | @Deprecated(
199 | message = "This function is deprecated. Use TextSwitch with the checked and onCheckedChange parameters instead.",
200 | replaceWith = ReplaceWith(
201 | "TextSwitch(checked = switchValue, onCheckedChange = onValueChanged, modifier = modifier, shape = shape, color = color, borderColor = borderColor, textColor = textColor, positiveText = positiveText, negativeText = negativeText)",
202 | "dev.muazkadan.switchycompose.TextSwitch"
203 | )
204 | )
205 | @Composable
206 | fun TextSwitch(
207 | modifier: Modifier = Modifier,
208 | shape: RoundedCornerShape = RoundedCornerShape(10.dp),
209 | switchValue: Boolean,
210 | color: Color = MaterialTheme.colorScheme.primaryContainer,
211 | borderColor: Color = MaterialTheme.colorScheme.primaryContainer,
212 | textColor: Color = MaterialTheme.colorScheme.onPrimaryContainer,
213 | positiveText: String = "Yes",
214 | negativeText: String = "No",
215 | onValueChanged: (Boolean) -> Unit,
216 | ) {
217 | TextSwitch(
218 | checked = switchValue,
219 | onCheckedChange = onValueChanged,
220 | modifier = modifier,
221 | shape = shape,
222 | color = color,
223 | borderColor = borderColor,
224 | textColor = textColor,
225 | positiveText = positiveText,
226 | negativeText = negativeText
227 | )
228 | }
229 |
230 | @Preview
231 | @Composable
232 | private fun TextSwitchPreview() {
233 | Column(
234 | modifier = Modifier.padding(16.dp),
235 | verticalArrangement = Arrangement.spacedBy(16.dp)
236 | ) {
237 | var checked1 by remember { mutableStateOf(true) }
238 | TextSwitch(
239 | checked = checked1,
240 | onCheckedChange = { checked1 = it }
241 | )
242 |
243 | var checked2 by remember { mutableStateOf(false) }
244 | TextSwitch(
245 | checked = checked2,
246 | onCheckedChange = { checked2 = it },
247 | positiveText = "ON",
248 | negativeText = "OFF"
249 | )
250 |
251 | // Disabled states
252 | TextSwitch(
253 | checked = true,
254 | onCheckedChange = null,
255 | enabled = false,
256 | positiveText = "Enabled",
257 | negativeText = "Disabled"
258 | )
259 | }
260 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Switchy Compose
2 |
3 | [](https://jitpack.io/#muazkadan/switchy-compose)
4 | [](https://central.sonatype.com/search?q=switchy-compose)
5 | [](https://android-arsenal.com/api?level=21)
6 | [](https://kotlinlang.org/docs/multiplatform.html)
7 | [](https://opensource.org/licenses/Apache-2.0)
8 |
9 | A modern, customizable switch component library for Jetpack Compose that provides beautiful animated switches with various styles and configurations, now with multiplatform support!
10 |
11 | ## 🎬 Preview
12 |
13 |
14 |
15 | ## ✨ Features
16 |
17 | - **Multiple Switch Variants**: 10 different switch styles to choose from
18 | - **Multiplatform Support**: Works on Android, iOS and Desktop
19 | - **Smooth Animations**: Fluid transitions with customizable duration and easing
20 | - **Highly Customizable**: Colors, shapes, sizes, and content can be tailored to your needs
21 | - **Jetpack Compose Native**: Built specifically for Compose with modern UI patterns
22 | - **Lightweight**: Minimal dependencies and optimized performance
23 | - **Material Design 3**: Follows Material Design guidelines and theming
24 | - **Platform Native Switches**: Use platform-specific native switch implementations
25 |
26 | ### Available Switch Types
27 |
28 | 1. **TextSwitch** - Switch with customizable text labels
29 | 2. **ColoredSwitch** - Simple colored switch with border
30 | 3. **ISwitch** - iOS-style switch with circular thumb
31 | 4. **IconISwitch** - iOS-style switch with custom icons
32 | 5. **CustomISwitch** - iOS-style switch with custom content
33 | 6. **CustomSwitch** - Fully customizable switch with custom content
34 | 7. **SquareSwitch** - Modern square-style switch
35 | 8. **NativeSwitch** - Platform-specific native switch implementation (Material3 on Android, UIKit on iOS)
36 |
37 | ## 🛠 Technology Stack
38 |
39 | - **Kotlin** - 100% Kotlin
40 | - **Jetpack Compose** - Modern Android UI toolkit
41 | - **Material Design 3** - Latest Material Design components
42 | - **Compose Animation** - Smooth and performant animations
43 | - **Gradle Version Catalogs** - Modern dependency management
44 |
45 | ## 📦 Installation
46 |
47 | ### Option 1: Maven Central (Recommended)
48 |
49 | The library is available on Maven Central. No additional repository setup is required.
50 |
51 | #### For Android projects:
52 |
53 | ```kotlin
54 | dependencies {
55 | implementation("dev.muazkadan:switchy-compose:0.7.0")
56 | }
57 | ```
58 |
59 | #### For Kotlin Multiplatform projects:
60 |
61 | ```kotlin
62 | commonMain.dependencies {
63 | implementation("dev.muazkadan:switchy-compose:0.7.0")
64 | }
65 | ```
66 |
67 |
68 | Option 2: JitPack (Legacy)
69 |
70 | #### Step 1: Add JitPack repository
71 |
72 | Add the JitPack repository to your project's `settings.gradle` file:
73 |
74 | ```kotlin
75 | dependencyResolutionManagement {
76 | repositories {
77 | google()
78 | mavenCentral()
79 | maven { url = uri("https://jitpack.io") }
80 | }
81 | }
82 | ```
83 |
84 | Or in your project-level `build.gradle` file:
85 |
86 | ```groovy
87 | allprojects {
88 | repositories {
89 | google()
90 | mavenCentral()
91 | maven { url 'https://jitpack.io' }
92 | }
93 | }
94 | ```
95 |
96 | #### Step 2: Add the dependency
97 |
98 | ```kotlin
99 | dependencies {
100 | implementation("com.github.muazkadan:switchy-compose:0.7.0")
101 | }
102 | ```
103 |
104 |
105 | ## 🚀 Usage
106 |
107 | ### Basic Usage
108 |
109 | ```kotlin
110 | @Composable
111 | fun MyScreen() {
112 | var switchValue by remember { mutableStateOf(false) }
113 |
114 | ISwitch(
115 | checked = switchValue,
116 | onCheckedChange = { switchValue = it }
117 | )
118 | }
119 | ```
120 |
121 | ### TextSwitch
122 |
123 | ```kotlin
124 | var switchValue by rememberSaveable { mutableStateOf(false) }
125 |
126 | TextSwitch(
127 | modifier = Modifier.padding(horizontal = 16.dp),
128 | checked = switchValue,
129 | positiveText = "ON",
130 | negativeText = "OFF",
131 | onCheckedChange = { switchValue = it }
132 | )
133 | ```
134 |
135 | ### ColoredSwitch
136 |
137 | ```kotlin
138 | var switchValue by rememberSaveable { mutableStateOf(false) }
139 |
140 | ColoredSwitch(
141 | modifier = Modifier
142 | .fillMaxWidth()
143 | .padding(horizontal = 16.dp),
144 | checked = switchValue,
145 | positiveColor = Color.Green,
146 | negativeColor = Color.Red,
147 | onCheckedChange = { switchValue = it }
148 | )
149 | ```
150 |
151 | ### ISwitch (iOS Style)
152 |
153 | ```kotlin
154 | var switchValue by rememberSaveable { mutableStateOf(false) }
155 |
156 | ISwitch(
157 | checked = switchValue,
158 | buttonHeight = 40.dp,
159 | positiveColor = Color(0xFF35C759),
160 | negativeColor = Color(0xFFE9E9EA),
161 | onCheckedChange = { switchValue = it }
162 | )
163 | ```
164 |
165 | ### IconISwitch
166 |
167 | ```kotlin
168 | var switchValue by rememberSaveable { mutableStateOf(false) }
169 |
170 | IconISwitch(
171 | checked = switchValue,
172 | positiveIcon = Icons.Default.Done,
173 | negativeIcon = Icons.Default.Close,
174 | onCheckedChange = { switchValue = it }
175 | )
176 | ```
177 |
178 | ### CustomISwitch
179 |
180 | ```kotlin
181 | var switchValue by rememberSaveable { mutableStateOf(false) }
182 |
183 | CustomISwitch(
184 | checked = switchValue,
185 | positiveContent = {
186 | Icon(
187 | imageVector = Icons.Default.Done,
188 | contentDescription = null,
189 | tint = Color.White
190 | )
191 | },
192 | negativeContent = {
193 | Text(
194 | text = "OFF",
195 | color = Color.Gray,
196 | fontSize = 10.sp
197 | )
198 | },
199 | onCheckedChange = { switchValue = it }
200 | )
201 | ```
202 |
203 | ### CustomSwitch
204 |
205 | ```kotlin
206 | var switchValue by rememberSaveable { mutableStateOf(false) }
207 |
208 | CustomSwitch(
209 | modifier = Modifier
210 | .fillMaxWidth()
211 | .padding(horizontal = 16.dp),
212 | checked = switchValue,
213 | positiveContent = {
214 | Icon(
215 | imageVector = Icons.Default.Done,
216 | contentDescription = null
217 | )
218 | },
219 | negativeContent = {
220 | Text("OFF")
221 | },
222 | onCheckedChange = { switchValue = it }
223 | )
224 | ```
225 |
226 | ### SquareSwitch
227 |
228 | ```kotlin
229 | var switchValue by rememberSaveable { mutableStateOf(false) }
230 |
231 | SquareSwitch(
232 | modifier = Modifier.padding(horizontal = 16.dp),
233 | checked = switchValue,
234 | shape = RoundedCornerShape(4.dp),
235 | onCheckedChange = { switchValue = it }
236 | )
237 | ```
238 |
239 | ### NativeSwitch
240 |
241 | ```kotlin
242 | var checked by rememberSaveable { mutableStateOf(false) }
243 |
244 | NativeSwitch(
245 | checked = checked,
246 | onCheckedChange = { checked = it },
247 | modifier = Modifier.padding(horizontal = 16.dp),
248 | enabled = true
249 | )
250 | ```
251 |
252 | ## 🎨 Customization
253 |
254 | ### Common Parameters
255 |
256 | - `modifier`: Modifier for the switch
257 | - `switchValue`: Current state of the switch
258 | - `onValueChanged`: Callback when switch state changes
259 | - `shape`: Custom shape for the switch
260 | - `positiveColor`/`negativeColor`: Colors for different states
261 |
262 | ### ISwitch Specific Parameters
263 |
264 | - `buttonHeight`: Height of the switch button
265 | - `innerPadding`: Padding inside the switch thumb
266 | - `positiveContent`/`negativeContent`: Custom composable content
267 |
268 | ## 🤝 Contributing
269 |
270 | Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
271 |
272 | ### Contributing Custom Switches
273 |
274 | Have an awesome custom Compose switch you'd like to share? We'd love to include it in Switchy Compose! Whether it's a unique animation, a creative design, or a platform-specific style, your custom switch could enhance the library. To contribute:
275 |
276 | 1. Ensure your switch is built with Jetpack Compose and follows Kotlin coding conventions.
277 | 2. Provide a clear API for your switch, similar to existing variants (e.g., `TextSwitch`, `ISwitch`).
278 | 3. Include documentation and a demo in the `app` module to showcase your switch.
279 | 4. Submit a Pull Request with your implementation, and we'll review it for inclusion.
280 |
281 | ## 📱 Demo App
282 |
283 | The project includes a demo app showcasing all switch variants. To run the demo:
284 |
285 | 1. Clone the repository
286 | 2. Open in Android Studio
287 | 3. Run the `app` module
288 |
289 | ### Development Setup
290 |
291 | 1. Fork the repository
292 | 2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
293 | 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
294 | 4. Push to the branch (`git push origin feature/AmazingFeature`)
295 | 5. Open a Pull Request
296 |
297 | ### Guidelines
298 |
299 | - Follow Kotlin coding conventions
300 | - Add tests for new features
301 | - Update documentation as needed
302 | - Ensure all existing tests pass
303 |
304 | ## 📄 License
305 |
306 | ```
307 | Copyright 2023 Muaz KADAN
308 |
309 | Licensed under the Apache License, Version 2.0 (the "License");
310 | you may not use this file except in compliance with the License.
311 | You may obtain a copy of the License at
312 |
313 | http://www.apache.org/licenses/LICENSE-2.0
314 |
315 | Unless required by applicable law or agreed to in writing, software
316 | distributed under the License is distributed on an "AS IS" BASIS,
317 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
318 | See the License for the specific language governing permissions and
319 | limitations under the License.
320 | ```
321 |
322 | ## 🙏 Acknowledgments
323 |
324 | - Built with [Jetpack Compose](https://developer.android.com/jetpack/compose)
325 | - [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) for cross-platform support
326 | - Inspired by iOS switch design patterns
327 | - Published via [Maven Central](https://central.sonatype.com/) and [JitPack](https://jitpack.io/)
328 |
329 | ---
330 |
331 | **Made with ❤️ by [Muaz KADAN](https://github.com/muazkadan)** | [LinkedIn](https://www.linkedin.com/in/muaz-kadan-727911107/)
332 |
333 |
334 |
--------------------------------------------------------------------------------
/switchycompose/multiplatform/src/commonMain/kotlin/dev/muazkadan/switchycompose/MorphingSwitch.kt:
--------------------------------------------------------------------------------
1 | package dev.muazkadan.switchycompose
2 |
3 | import androidx.compose.animation.animateColorAsState
4 | import androidx.compose.animation.core.*
5 | import androidx.compose.animation.core.animateDpAsState
6 | import androidx.compose.animation.core.animateFloatAsState
7 | import androidx.compose.foundation.background
8 | import androidx.compose.foundation.border
9 | import androidx.compose.foundation.interaction.MutableInteractionSource
10 | import androidx.compose.foundation.interaction.collectIsPressedAsState
11 | import androidx.compose.foundation.layout.*
12 | import androidx.compose.foundation.selection.toggleable
13 | import androidx.compose.foundation.shape.CircleShape
14 | import androidx.compose.foundation.shape.RoundedCornerShape
15 | import androidx.compose.runtime.*
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.draw.alpha
19 | import androidx.compose.ui.draw.clip
20 | import androidx.compose.ui.draw.scale
21 | import androidx.compose.ui.graphics.Color
22 | import androidx.compose.ui.graphics.TransformOrigin
23 | import androidx.compose.ui.graphics.graphicsLayer
24 | import androidx.compose.ui.semantics.Role
25 | import androidx.compose.ui.unit.Dp
26 | import androidx.compose.ui.unit.dp
27 | import org.jetbrains.compose.ui.tooling.preview.Preview
28 |
29 | /**
30 | * A composable function that creates an interactive morphing switch with shape transitions,
31 | * pulsing animations, and dynamic visual feedback.
32 | *
33 | * @param checked The current checked state of the switch.
34 | * @param onCheckedChange Callback invoked when the switch state changes.
35 | * @param modifier The modifier to be applied to the switch.
36 | * @param enabled Whether the switch is enabled and can be interacted with.
37 | * @param switchHeight The height of the switch. Default is 48.dp.
38 | * @param switchWidth The width of the switch. Default is 80.dp.
39 | * @param positiveColor The color of the switch when it's in the 'on' state. Default is a vibrant blue.
40 | * @param negativeColor The color of the switch when it's in the 'off' state. Default is a subtle gray.
41 | * @param disabledPositiveColor The color when checked but disabled.
42 | * @param disabledNegativeColor The color when unchecked and disabled.
43 | * @param thumbColor The color of the thumb. Default is white.
44 | * @param enablePulseAnimation Whether to enable the pulsing animation effect. Default is true.
45 | * @param enableMorphing Whether to enable shape morphing animation. Default is true.
46 | * @param interactionSource The MutableInteractionSource representing the stream of interactions with this switch.
47 | */
48 | @Composable
49 | fun MorphingSwitch(
50 | checked: Boolean,
51 | onCheckedChange: ((Boolean) -> Unit)?,
52 | modifier: Modifier = Modifier,
53 | enabled: Boolean = true,
54 | switchHeight: Dp = 48.dp,
55 | switchWidth: Dp = 80.dp,
56 | positiveColor: Color = Color(0xFF2196F3),
57 | negativeColor: Color = Color(0xFFE0E0E0),
58 | disabledPositiveColor: Color = positiveColor.copy(alpha = 0.38f),
59 | disabledNegativeColor: Color = negativeColor.copy(alpha = 0.38f),
60 | thumbColor: Color = Color.White,
61 | enablePulseAnimation: Boolean = true,
62 | enableMorphing: Boolean = true,
63 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
64 | ) {
65 | val isPressed by interactionSource.collectIsPressedAsState()
66 |
67 | // Pulsing animation
68 | val pulseAnimation by animateFloatAsState(
69 | targetValue = if (enablePulseAnimation && isPressed) 1.1f else 1f,
70 | animationSpec = spring(
71 | dampingRatio = Spring.DampingRatioMediumBouncy,
72 | stiffness = Spring.StiffnessLow
73 | ),
74 | label = "pulse_animation"
75 | )
76 |
77 | // Morphing animation for shape transition
78 | val morphingProgress by animateFloatAsState(
79 | targetValue = if (checked) 1f else 0f,
80 | animationSpec = spring(
81 | dampingRatio = Spring.DampingRatioMediumBouncy,
82 | stiffness = Spring.StiffnessMedium
83 | ),
84 | label = "morphing_progress"
85 | )
86 |
87 | // Thumb position animation
88 | val thumbOffset by animateDpAsState(
89 | targetValue = if (checked) switchWidth - switchHeight else 0.dp,
90 | animationSpec = spring(
91 | dampingRatio = Spring.DampingRatioMediumBouncy,
92 | stiffness = Spring.StiffnessMedium
93 | ),
94 | label = "thumb_offset"
95 | )
96 |
97 | // Background color animation
98 | val animatedBackgroundColor by animateColorAsState(
99 | targetValue = when {
100 | !enabled && checked -> disabledPositiveColor
101 | !enabled && !checked -> disabledNegativeColor
102 | checked -> positiveColor
103 | else -> negativeColor
104 | },
105 | animationSpec = tween(
106 | durationMillis = 300,
107 | easing = FastOutSlowInEasing
108 | ),
109 | label = "background_color"
110 | )
111 |
112 | // Alpha animation for disabled state
113 | val animatedAlpha by animateFloatAsState(
114 | targetValue = if (enabled) 1f else 0.5f,
115 | animationSpec = tween(durationMillis = 200),
116 | label = "alpha_animation"
117 | )
118 |
119 | // Rotation animation for thumb
120 | val thumbRotation by animateFloatAsState(
121 | targetValue = if (checked) 180f else 0f,
122 | animationSpec = spring(
123 | dampingRatio = Spring.DampingRatioMediumBouncy,
124 | stiffness = Spring.StiffnessMedium
125 | ),
126 | label = "thumb_rotation"
127 | )
128 |
129 | // Scale animation for thumb
130 | val thumbScale by animateFloatAsState(
131 | targetValue = if (isPressed) 0.9f else 1f,
132 | animationSpec = spring(
133 | dampingRatio = Spring.DampingRatioMediumBouncy,
134 | stiffness = Spring.StiffnessHigh
135 | ),
136 | label = "thumb_scale"
137 | )
138 |
139 | // Morphing shape calculation
140 | val morphingShape = if (enableMorphing) {
141 | val cornerRadius = lerp(switchHeight / 2, 8.dp, morphingProgress)
142 | RoundedCornerShape(cornerRadius)
143 | } else {
144 | RoundedCornerShape(switchHeight / 2)
145 | }
146 |
147 | Box(
148 | modifier = modifier
149 | .size(width = switchWidth, height = switchHeight)
150 | .scale(pulseAnimation)
151 | .clip(morphingShape)
152 | .background(animatedBackgroundColor)
153 | .alpha(animatedAlpha)
154 | .then(
155 | if (onCheckedChange != null) {
156 | Modifier.toggleable(
157 | value = checked,
158 | enabled = enabled,
159 | role = Role.Switch,
160 | interactionSource = interactionSource,
161 | indication = null,
162 | onValueChange = onCheckedChange
163 | )
164 | } else {
165 | Modifier
166 | }
167 | )
168 | ) {
169 | // Thumb with morphing and rotation effects
170 | Box(
171 | modifier = Modifier
172 | .size(switchHeight - 8.dp)
173 | .offset(x = thumbOffset + 4.dp, y = 4.dp)
174 | .scale(thumbScale)
175 | .graphicsLayer(
176 | rotationZ = thumbRotation,
177 | transformOrigin = TransformOrigin(0.5f, 0.5f)
178 | )
179 | .clip(CircleShape)
180 | .background(thumbColor)
181 | .border(
182 | width = 2.dp,
183 | color = animatedBackgroundColor.copy(alpha = 0.3f),
184 | shape = CircleShape
185 | )
186 | ) {
187 | // Inner decorative element that changes based on state
188 | Box(
189 | modifier = Modifier
190 | .size(switchHeight / 3)
191 | .align(Alignment.Center)
192 | .clip(CircleShape)
193 | .background(
194 | if (checked) positiveColor.copy(alpha = 0.7f)
195 | else negativeColor.copy(alpha = 0.3f)
196 | )
197 | )
198 | }
199 |
200 | // Ripple effect overlay
201 | if (isPressed) {
202 | Box(
203 | modifier = Modifier
204 | .fillMaxSize()
205 | .clip(morphingShape)
206 | .background(
207 | Color.White.copy(alpha = 0.2f)
208 | )
209 | )
210 | }
211 | }
212 | }
213 |
214 | /**
215 | * Helper function to interpolate between two Dp values
216 | */
217 | private fun lerp(start: Dp, end: Dp, fraction: Float): Dp {
218 | return start + (end - start) * fraction
219 | }
220 |
221 |
222 | @Preview
223 | @Composable
224 | private fun MorphingSwitchPreview() {
225 | Column(
226 | modifier = Modifier.padding(16.dp),
227 | verticalArrangement = Arrangement.spacedBy(16.dp)
228 | ) {
229 | var checked1 by remember { mutableStateOf(true) }
230 | MorphingSwitch(
231 | checked = checked1,
232 | onCheckedChange = { checked1 = it }
233 | )
234 |
235 | var checked2 by remember { mutableStateOf(false) }
236 | MorphingSwitch(
237 | checked = checked2,
238 | onCheckedChange = { checked2 = it }
239 | )
240 |
241 | var checked3 by remember { mutableStateOf(true) }
242 | MorphingSwitch(
243 | checked = checked3,
244 | onCheckedChange = { checked3 = it },
245 | positiveColor = Color(0xFFE91E63),
246 | negativeColor = Color(0xFFF5F5F5),
247 | thumbColor = Color(0xFFFFEB3B)
248 | )
249 |
250 | // Disabled state
251 | MorphingSwitch(
252 | checked = true,
253 | onCheckedChange = null,
254 | enabled = false
255 | )
256 |
257 | // Without morphing
258 | var checked4 by remember { mutableStateOf(false) }
259 | MorphingSwitch(
260 | checked = checked4,
261 | onCheckedChange = { checked4 = it },
262 | enableMorphing = false
263 | )
264 |
265 | // Without pulse animation
266 | var checked5 by remember { mutableStateOf(true) }
267 | MorphingSwitch(
268 | checked = checked5,
269 | onCheckedChange = { checked5 = it },
270 | enablePulseAnimation = false
271 | )
272 | }
273 | }
--------------------------------------------------------------------------------