├── 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 | 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/v/muazkadan/switchy-compose.svg)](https://jitpack.io/#muazkadan/switchy-compose) 4 | [![Maven Central](https://img.shields.io/maven-central/v/dev.muazkadan/switchy-compose)](https://central.sonatype.com/search?q=switchy-compose) 5 | [![API](https://img.shields.io/badge/API-21%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=21) 6 | [![Kotlin Multiplatform](https://img.shields.io/badge/Kotlin-Multiplatform-blue.svg)](https://kotlinlang.org/docs/multiplatform.html) 7 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](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 | Switchy Compose Demo 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 | } --------------------------------------------------------------------------------