├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── values
│ │ │ │ ├── strings.xml
│ │ │ │ ├── themes.xml
│ │ │ │ └── colors.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ └── data_extraction_rules.xml
│ │ │ └── drawable
│ │ │ │ ├── ic_launcher_foreground.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── dongchyeon
│ │ │ │ └── timepicker
│ │ │ │ ├── ui
│ │ │ │ └── theme
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ ├── Type.kt
│ │ │ │ │ └── Theme.kt
│ │ │ │ └── MainActivity.kt
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── com
│ │ │ └── dongchyeon
│ │ │ └── timepicker
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── com
│ │ └── dongchyeon
│ │ └── timepicker
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle.kts
├── timepicker
├── .gitignore
├── consumer-rules.pro
├── src
│ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── com
│ │ │ └── dongchyeon
│ │ │ └── timepicker
│ │ │ ├── ui
│ │ │ ├── util
│ │ │ │ └── DpToPx.kt
│ │ │ └── PickerItem.kt
│ │ │ ├── model
│ │ │ └── PickerState.kt
│ │ │ ├── TimePickerDefaults.kt
│ │ │ └── TimePicker.kt
│ ├── test
│ │ └── java
│ │ │ └── com
│ │ │ └── dongchyeon
│ │ │ └── timepicker
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── com
│ │ └── dongchyeon
│ │ └── timepicker
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle.kts
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── .github
├── ISSUE_TEMPLATE
│ ├── feature.md
│ ├── refactor.md
│ ├── test.md
│ └── bug.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ └── android_ci.yml
├── settings.gradle.kts
├── LICENSE
├── gradle.properties
├── .gitignore
├── gradlew.bat
├── README.md
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/timepicker/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/timepicker/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/timepicker/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | TimePicker
3 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DongChyeon/TimePicker/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DongChyeon/TimePicker/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DongChyeon/TimePicker/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DongChyeon/TimePicker/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DongChyeon/TimePicker/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DongChyeon/TimePicker/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DongChyeon/TimePicker/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DongChyeon/TimePicker/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DongChyeon/TimePicker/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DongChyeon/TimePicker/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DongChyeon/TimePicker/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Fri Mar 28 10:39:39 KST 2025
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/timepicker/src/main/java/com/dongchyeon/timepicker/ui/util/DpToPx.kt:
--------------------------------------------------------------------------------
1 | package com.dongchyeon.timepicker.ui.util
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.platform.LocalDensity
5 | import androidx.compose.ui.unit.Dp
6 |
7 | @Composable
8 | internal fun Dp.toPx(): Float {
9 | return with(LocalDensity.current) { this@toPx.toPx() }
10 | }
11 |
--------------------------------------------------------------------------------
/app/src/main/java/com/dongchyeon/timepicker/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package com.dongchyeon.timepicker.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
12 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "✨ Feature"
3 | about: "Suggest a new feature or enhancement"
4 | title: "✨ Feature - "
5 | labels: ["enhancement"]
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## ✨ Feature
11 |
12 | **What to add?**
13 |
14 | > e.g. Dark mode support for TimePicker.
15 |
16 | **Why?**
17 |
18 | > e.g. Consistent UX with app theme.
19 |
20 | ### ✅ Checklist
21 |
22 | - [ ] Feature implemented
23 | - [ ] Tests added
24 | - [ ] Docs updated
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/refactor.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "♻️ Refactor"
3 | about: "Refactor existing code for clarity or performance"
4 | title: "♻️ Refactor - "
5 | labels: ["enhancement"]
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## ♻️ Refactor
11 |
12 | **What to refactor?**
13 |
14 | > e.g. Replace mutable selectedItem with index-based state.
15 |
16 | **Why?**
17 |
18 | > e.g. Enforce unidirectional data flow.
19 |
20 | ### ✅ Checklist
21 |
22 | - [ ] Code updated
23 | - [ ] Tests pass
--------------------------------------------------------------------------------
/app/src/test/java/com/dongchyeon/timepicker/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.dongchyeon.timepicker
2 |
3 | import org.junit.Assert.assertEquals
4 | import org.junit.Test
5 |
6 | /**
7 | * Example local unit test, which will execute on the development machine (host).
8 | *
9 | * See [testing documentation](http://d.android.com/tools/testing).
10 | */
11 | class ExampleUnitTest {
12 | @Test
13 | fun addition_isCorrect() {
14 | assertEquals(4, 2 + 2)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/test.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "✅ Test"
3 | about: "Add or update tests"
4 | title: "✅ Test - "
5 | labels: ["test"]
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## 🧪 Test Task
11 |
12 | **What to test?**
13 |
14 | > e.g. Add unit tests for PickerState index management.
15 |
16 | **Why?**
17 |
18 | > e.g. Ensure correct selectedIndex and selectedItem behavior.
19 |
20 | ### ✅ Checklist
21 |
22 | - [ ] Covers main logic
23 | - [ ] Edge cases
24 | - [ ] Integration (if needed)
--------------------------------------------------------------------------------
/timepicker/src/test/java/com/dongchyeon/timepicker/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.dongchyeon.timepicker
2 |
3 | import org.junit.Assert.assertEquals
4 | import org.junit.Test
5 |
6 | /**
7 | * Example local unit test, which will execute on the development machine (host).
8 | *
9 | * See [testing documentation](http://d.android.com/tools/testing).
10 | */
11 | class ExampleUnitTest {
12 | @Test
13 | fun addition_isCorrect() {
14 | assertEquals(4, 2 + 2)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "🐞 Bug"
3 | about: "Report a bug or unexpected behavior"
4 | title: "🐞 Bug - "
5 | labels: ["bug"]
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## 🐞 Bug
11 |
12 | **What’s wrong?**
13 |
14 | > e.g. Crash when selecting hour 23 in 24-hour mode.
15 |
16 | **Steps to reproduce:**
17 |
18 | 1. …
19 | 2. …
20 | 3. …
21 |
22 | **Expected:**
23 | e.g. No crash when scrolling to hour 23.
24 |
25 | **Actual:**
26 | e.g. App crashes with IndexOutOfBoundsException.
27 |
28 | ### ✅ Checklist
29 |
30 | - [ ] Bug fixed
31 | - [ ] Test added (if needed)
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16 | repositories {
17 | google()
18 | mavenCentral()
19 | }
20 | }
21 |
22 | rootProject.name = "TimePicker"
23 | include(":app")
24 | include(":timepicker")
25 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## 🎯 Related Issue
2 |
3 | - Closes #issue-number
4 |
5 | ---
6 |
7 | ## 📝 Description
8 |
9 | **What does this PR do?**
10 |
11 | > e.g. Refactor PickerState to use index-based StateFlow implementation.
12 |
13 | ---
14 |
15 | ## ✅ Changes
16 |
17 | - [ ] Replace mutable selectedItem with derived getter.
18 | - [ ] Add selectedIndex StateFlow and update function.
19 | - [ ] Update rememberPickerState API.
20 | - [ ] Refactor usages in PickerItem and TimePicker.
21 |
22 | ---
23 |
24 | ## 🔍 Screenshots / Test Results (if applicable)
25 |
26 | _Add any relevant screenshots or test output here._
27 |
28 | ---
29 |
30 | ## 👤 Reviewer Checklist
31 |
32 | - [ ] Code quality and style
33 | - [ ] Functionality works as expected
34 | - [ ] No breaking changes introduced
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/dongchyeon/timepicker/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.dongchyeon.timepicker
2 |
3 | import androidx.test.ext.junit.runners.AndroidJUnit4
4 | import androidx.test.platform.app.InstrumentationRegistry
5 | import org.junit.Assert.assertEquals
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | /**
10 | * Instrumented test, which will execute on an Android device.
11 | *
12 | * See [testing documentation](http://d.android.com/tools/testing).
13 | */
14 | @RunWith(AndroidJUnit4::class)
15 | class ExampleInstrumentedTest {
16 | @Test
17 | fun useAppContext() {
18 | // Context of the app under test.
19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
20 | assertEquals("com.dongchyeon.timepicker", appContext.packageName)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/timepicker/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/timepicker/src/androidTest/java/com/dongchyeon/timepicker/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.dongchyeon.timepicker
2 |
3 | import androidx.test.ext.junit.runners.AndroidJUnit4
4 | import androidx.test.platform.app.InstrumentationRegistry
5 | import org.junit.Assert.assertEquals
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | /**
10 | * Instrumented test, which will execute on an Android device.
11 | *
12 | * See [testing documentation](http://d.android.com/tools/testing).
13 | */
14 | @RunWith(AndroidJUnit4::class)
15 | class ExampleInstrumentedTest {
16 | @Test
17 | fun useAppContext() {
18 | // Context of the app under test.
19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
20 | assertEquals("com.dongchyeon.timepicker.test", appContext.packageName)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 DongChyeon
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
15 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/app/src/main/java/com/dongchyeon/timepicker/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.dongchyeon.timepicker.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp
17 | )
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
35 |
--------------------------------------------------------------------------------
/timepicker/src/main/java/com/dongchyeon/timepicker/model/PickerState.kt:
--------------------------------------------------------------------------------
1 | package com.dongchyeon.timepicker.model
2 |
3 | import androidx.compose.foundation.lazy.LazyListState
4 | import androidx.compose.foundation.lazy.rememberLazyListState
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.remember
7 | import kotlinx.coroutines.flow.MutableStateFlow
8 | import kotlinx.coroutines.flow.StateFlow
9 |
10 | class PickerState(
11 | val lazyListState: LazyListState,
12 | val initialIndex: Int,
13 | private val items: List
14 | ) {
15 | private val _selectedIndex = MutableStateFlow(initialIndex)
16 | val selectedIndex: StateFlow
17 | get() = _selectedIndex
18 |
19 | val selectedItem: T
20 | get() = items.getOrElse(_selectedIndex.value) { items.first() }
21 |
22 | fun updateSelectedIndex(newIndex: Int) {
23 | _selectedIndex.value = newIndex.coerceIn(0, items.size - 1)
24 | }
25 | }
26 |
27 | @Composable
28 | fun rememberPickerState(
29 | lazyListState: LazyListState = rememberLazyListState(),
30 | initialIndex: Int = 0,
31 | items: List
32 | ): PickerState = remember { PickerState(lazyListState, initialIndex, items) }
33 |
--------------------------------------------------------------------------------
/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. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-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
--------------------------------------------------------------------------------
/.github/workflows/android_ci.yml:
--------------------------------------------------------------------------------
1 | name: TimePicker CI
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | paths:
7 | - 'app/**'
8 | - 'build.gradle'
9 | - '**/*.kt'
10 | pull_request:
11 | branches: [main]
12 | paths:
13 | - 'app/**'
14 | - 'build.gradle'
15 | - '**/*.kt'
16 |
17 | jobs:
18 | build:
19 | runs-on: ubuntu-latest
20 | steps:
21 | # Gradle Cache
22 | - name: Cache Gradle packages
23 | uses: actions/cache@v4
24 | with:
25 | path: |
26 | ~/.gradle/caches
27 | ~/.gradle/wrapper
28 | key: ${{ runner.os }}-gradle-${{ hashFiles('gradle.properties', '**/*.gradle*', '**/gradle-wrapper.properties') }}
29 | restore-keys: |
30 | ${{ runner.os }}-gradle-
31 |
32 | # Checkout Code
33 | - name: Checkout the code
34 | uses: actions/checkout@v4
35 |
36 | # Setup JDK
37 | - name: Setup JDK
38 | uses: actions/setup-java@v4
39 | with:
40 | distribution: 'corretto'
41 | java-version: '17'
42 |
43 | # Setup Android SDK
44 | - name: Setup Android SDK
45 | uses: android-actions/setup-android@v3
46 |
47 | # Grant Execute Permission
48 | - name: Grant execute permission for gradlew
49 | run: chmod +x gradlew
50 |
51 | # Run Lint and Build
52 | - name: Run lint and build
53 | run: ./gradlew ktlintCheck assembleDebug
54 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/app/src/main/java/com/dongchyeon/timepicker/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.dongchyeon.timepicker.ui.theme
2 |
3 | import android.os.Build
4 | import androidx.compose.foundation.isSystemInDarkTheme
5 | import androidx.compose.material3.MaterialTheme
6 | import androidx.compose.material3.darkColorScheme
7 | import androidx.compose.material3.dynamicDarkColorScheme
8 | import androidx.compose.material3.dynamicLightColorScheme
9 | import androidx.compose.material3.lightColorScheme
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.platform.LocalContext
12 |
13 | private val DarkColorScheme = darkColorScheme(
14 | primary = Purple80,
15 | secondary = PurpleGrey80,
16 | tertiary = Pink80
17 | )
18 |
19 | private val LightColorScheme = lightColorScheme(
20 | primary = Purple40,
21 | secondary = PurpleGrey40,
22 | tertiary = Pink40
23 |
24 | /* Other default colors to override
25 | background = Color(0xFFFFFBFE),
26 | surface = Color(0xFFFFFBFE),
27 | onPrimary = Color.White,
28 | onSecondary = Color.White,
29 | onTertiary = Color.White,
30 | onBackground = Color(0xFF1C1B1F),
31 | onSurface = Color(0xFF1C1B1F),
32 | */
33 | )
34 |
35 | @Composable
36 | fun TimePickerTheme(
37 | darkTheme: Boolean = isSystemInDarkTheme(),
38 | // Dynamic color is available on Android 12+
39 | dynamicColor: Boolean = true,
40 | content: @Composable () -> Unit
41 | ) {
42 | val colorScheme = when {
43 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
44 | val context = LocalContext.current
45 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
46 | }
47 |
48 | darkTheme -> DarkColorScheme
49 | else -> LightColorScheme
50 | }
51 |
52 | MaterialTheme(
53 | colorScheme = colorScheme,
54 | typography = Typography,
55 | content = content
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.application)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.kotlin.compose)
5 | }
6 |
7 | android {
8 | namespace = "com.dongchyeon.timepicker"
9 | compileSdk = 35
10 |
11 | defaultConfig {
12 | applicationId = "com.dongchyeon.timepicker"
13 | minSdk = 24
14 | targetSdk = 35
15 | versionCode = 1
16 | versionName = "1.0"
17 |
18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
19 | }
20 |
21 | buildTypes {
22 | release {
23 | isMinifyEnabled = false
24 | proguardFiles(
25 | getDefaultProguardFile("proguard-android-optimize.txt"),
26 | "proguard-rules.pro"
27 | )
28 | }
29 | }
30 | compileOptions {
31 | sourceCompatibility = JavaVersion.VERSION_11
32 | targetCompatibility = JavaVersion.VERSION_11
33 | }
34 | kotlinOptions {
35 | jvmTarget = "11"
36 | }
37 | buildFeatures {
38 | compose = true
39 | }
40 | }
41 |
42 | dependencies {
43 | implementation(project(":timepicker"))
44 |
45 | implementation(libs.androidx.core.ktx)
46 | implementation(libs.androidx.lifecycle.runtime.ktx)
47 | implementation(libs.androidx.activity.compose)
48 | implementation(platform(libs.androidx.compose.bom))
49 | implementation(libs.androidx.ui)
50 | implementation(libs.androidx.ui.graphics)
51 | implementation(libs.androidx.ui.tooling.preview)
52 | implementation(libs.androidx.material3)
53 | implementation(libs.kotlinx.datetime)
54 | testImplementation(libs.junit)
55 | androidTestImplementation(libs.androidx.junit)
56 | androidTestImplementation(libs.androidx.espresso.core)
57 | androidTestImplementation(platform(libs.androidx.compose.bom))
58 | androidTestImplementation(libs.androidx.ui.test.junit4)
59 | debugImplementation(libs.androidx.ui.tooling)
60 | debugImplementation(libs.androidx.ui.test.manifest)
61 | }
62 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Built application files
2 | *.apk
3 | *.aar
4 | *.ap_
5 | *.aab
6 |
7 | # Files for the ART/Dalvik VM
8 | *.dex
9 |
10 | # Java class files
11 | *.class
12 |
13 | # Generated files
14 | bin/
15 | gen/
16 | out/
17 | # Uncomment the following line in case you need and you don't have the release build type files in your app
18 | # release/
19 |
20 | # Gradle files
21 | .gradle/
22 | build/
23 |
24 | # Local configuration file (sdk path, etc)
25 | local.properties
26 | keystore.properties
27 | output-metadata.json
28 |
29 | # Proguard folder generated by Eclipse
30 | proguard/
31 |
32 | # Log Files
33 | *.log
34 |
35 | # Android Studio Navigation editor temp files
36 | .navigation/
37 |
38 | # Android Studio captures folder
39 | captures/
40 |
41 | # IntelliJ
42 | *.iml
43 | .idea/workspace.xml
44 | .idea/tasks.xml
45 | .idea/gradle.xml
46 | .idea/assetWizardSettings.xml
47 | .idea/dictionaries
48 | .idea/libraries
49 | # Android Studio 3 in .gitignore file.
50 | .idea/caches
51 | .idea/modules.xml
52 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you
53 | .idea/navEditor.xml
54 |
55 | # Keystore files
56 | # Uncomment the following lines if you do not want to check your keystore files in.
57 | #*.jks
58 | #*.keystore
59 |
60 | # External native build folder generated in Android Studio 2.2 and later
61 | .externalNativeBuild
62 | .cxx/
63 |
64 | # Google Services (e.g. APIs or Firebase)
65 | # google-services.json
66 |
67 | # Freeline
68 | freeline.py
69 | freeline/
70 | freeline_project_description.json
71 |
72 | # fastlane
73 | fastlane/report.xml
74 | fastlane/Preview.html
75 | fastlane/screenshots
76 | fastlane/test_output
77 | fastlane/readme.md
78 |
79 | # Version control
80 | vcs.xml
81 |
82 | # lint
83 | lint/intermediates/
84 | lint/generated/
85 | lint/outputs/
86 | lint/tmp/
87 | # lint/reports/
88 |
89 |
90 | # Windows thumbnail db
91 | Thumbs.db
92 |
93 | # OSX files
94 | .DS_Store
95 |
96 | # Android Studio
97 | .idea
98 | #.idea/workspace.xml - remove # and delete .idea if it better suit your needs.
99 | .gradle
100 | .navigation
101 | output.json
102 | google-services.json
103 |
104 | #NDK
105 | obj/
--------------------------------------------------------------------------------
/app/src/main/java/com/dongchyeon/timepicker/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.dongchyeon.timepicker
2 |
3 | import android.os.Bundle
4 | import android.util.Log
5 | import androidx.activity.ComponentActivity
6 | import androidx.activity.compose.setContent
7 | import androidx.activity.enableEdgeToEdge
8 | import androidx.compose.foundation.BorderStroke
9 | import androidx.compose.foundation.background
10 | import androidx.compose.foundation.layout.Arrangement
11 | import androidx.compose.foundation.layout.Box
12 | import androidx.compose.foundation.layout.Column
13 | import androidx.compose.foundation.layout.fillMaxSize
14 | import androidx.compose.foundation.shape.RoundedCornerShape
15 | import androidx.compose.ui.Alignment
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.graphics.Color
18 | import androidx.compose.ui.unit.dp
19 | import com.dongchyeon.timepicker.ui.theme.TimePickerTheme
20 |
21 | class MainActivity : ComponentActivity() {
22 | override fun onCreate(savedInstanceState: Bundle?) {
23 | super.onCreate(savedInstanceState)
24 | enableEdgeToEdge()
25 | setContent {
26 | TimePickerTheme {
27 | Box(
28 | modifier = Modifier
29 | .fillMaxSize()
30 | .background(color = Color.Black),
31 | contentAlignment = Alignment.Center
32 | ) {
33 | Column(
34 | verticalArrangement = Arrangement.spacedBy(20.dp)
35 | ) {
36 | TimePicker(
37 | timeFormat = TimeFormat.TWENTY_FOUR_HOUR
38 | ) { newTime ->
39 | Log.d("TimePicker", "Selected Time: $newTime")
40 | }
41 |
42 | TimePicker(
43 | selector = TimePickerDefaults.pickerSelector(
44 | color = Color.Gray.copy(alpha = 0.4f),
45 | shape = RoundedCornerShape(16.dp),
46 | border = BorderStroke(1.dp, Color.Gray)
47 | )
48 | ) { newTime ->
49 | Log.d("TimePicker", "Selected Time: $newTime")
50 | }
51 | }
52 | }
53 | }
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.7.3"
3 | kotlin = "2.0.0"
4 | coreKtx = "1.10.0"
5 | junit = "4.13.2"
6 | junitVersion = "1.2.1"
7 | espressoCore = "3.6.1"
8 | lifecycleRuntimeKtx = "2.8.7"
9 | activityCompose = "1.9.3"
10 | composeBom = "2024.04.01"
11 | appcompat = "1.7.0"
12 | material = "1.12.0"
13 | ktlint = "11.5.1"
14 | foundationAndroid = "1.7.8"
15 | kotlinxDateTime = "0.6.2"
16 | mavenPublish = "0.29.0"
17 |
18 | [libraries]
19 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
20 | junit = { group = "junit", name = "junit", version.ref = "junit" }
21 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
22 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
23 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
24 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
25 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
26 | androidx-ui = { group = "androidx.compose.ui", name = "ui" }
27 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
28 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
29 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
30 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
31 | androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
32 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
33 | androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
34 | material = { group = "com.google.android.material", name = "material", version.ref = "material" }
35 | androidx-foundation-android = { group = "androidx.compose.foundation", name = "foundation-android", version.ref = "foundationAndroid" }
36 | kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDateTime" }
37 |
38 | [plugins]
39 | android-application = { id = "com.android.application", version.ref = "agp" }
40 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
41 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
42 | android-library = { id = "com.android.library", version.ref = "agp" }
43 | ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
44 | maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
45 |
--------------------------------------------------------------------------------
/timepicker/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import com.vanniktech.maven.publish.SonatypeHost
2 |
3 | plugins {
4 | alias(libs.plugins.android.library)
5 | alias(libs.plugins.kotlin.android)
6 | alias(libs.plugins.kotlin.compose)
7 | alias(libs.plugins.maven.publish)
8 | }
9 |
10 | android {
11 | namespace = "com.dongchyeon.timepicker"
12 | compileSdk = 35
13 |
14 | defaultConfig {
15 | minSdk = 24
16 |
17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
18 | consumerProguardFiles("consumer-rules.pro")
19 | }
20 |
21 | buildTypes {
22 | release {
23 | isMinifyEnabled = false
24 | proguardFiles(
25 | getDefaultProguardFile("proguard-android-optimize.txt"),
26 | "proguard-rules.pro"
27 | )
28 | }
29 | }
30 | compileOptions {
31 | sourceCompatibility = JavaVersion.VERSION_11
32 | targetCompatibility = JavaVersion.VERSION_11
33 | }
34 | kotlinOptions {
35 | jvmTarget = "11"
36 | }
37 | }
38 |
39 | dependencies {
40 |
41 | implementation(libs.androidx.core.ktx)
42 | implementation(libs.androidx.appcompat)
43 | implementation(libs.material)
44 | implementation(platform(libs.androidx.compose.bom))
45 | implementation(libs.androidx.ui)
46 | implementation(libs.androidx.ui.graphics)
47 | implementation(libs.androidx.ui.tooling)
48 | implementation(libs.androidx.ui.tooling.preview)
49 | implementation(libs.androidx.foundation.android)
50 | implementation(libs.androidx.material3)
51 | implementation(libs.kotlinx.datetime)
52 | testImplementation(libs.junit)
53 | androidTestImplementation(libs.androidx.junit)
54 | androidTestImplementation(libs.androidx.espresso.core)
55 | }
56 |
57 | mavenPublishing {
58 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
59 |
60 | signAllPublications()
61 |
62 | coordinates("io.github.dongchyeon", "time-picker", "1.1.1")
63 |
64 | pom {
65 | name.set("TimePicker")
66 | description.set("This project features a customizable Jetpack Compose time picker component.")
67 | url.set("https://github.com/DongChyeon/TimePicker.git")
68 | inceptionYear.set("2025")
69 |
70 | licenses {
71 | license {
72 | name.set("The Apache License, Version 2.0")
73 | url.set("http://www.apache.org/licenses/LICENSE-2.0.txt")
74 | distribution.set("http://www.apache.org/licenses/LICENSE-2.0.txt")
75 | }
76 | }
77 |
78 | developers {
79 | developer {
80 | id.set("DongChyeon")
81 | name.set("DongChyeon")
82 | url.set("https://github.com/DongChyeon")
83 | }
84 | }
85 |
86 | scm {
87 | url.set("https://github.com/DongChyeon/TimePicker.git")
88 | connection.set("scm:git:git://github.com/DongChyeon/TimePicker.git")
89 | developerConnection.set("scm:git:ssh://git@github.com/DongChyeon/TimePicker.git")
90 | }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/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 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/timepicker/src/main/java/com/dongchyeon/timepicker/TimePickerDefaults.kt:
--------------------------------------------------------------------------------
1 | package com.dongchyeon.timepicker
2 |
3 | import androidx.compose.foundation.BorderStroke
4 | import androidx.compose.foundation.shape.RoundedCornerShape
5 | import androidx.compose.material3.MaterialTheme
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.Immutable
8 | import androidx.compose.ui.graphics.Color
9 | import androidx.compose.ui.text.TextStyle
10 | import androidx.compose.ui.unit.Dp
11 | import androidx.compose.ui.unit.dp
12 |
13 | object TimePickerDefaults {
14 | @Composable
15 | fun pickerStyle(
16 | textStyle: TextStyle = MaterialTheme.typography.titleMedium,
17 | textColor: Color = Color.White,
18 | itemSpacing: Dp = 2.dp
19 | ): PickerStyle {
20 | return PickerStyle(
21 | textStyle = textStyle,
22 | textColor = textColor,
23 | itemSpacing = itemSpacing
24 | )
25 | }
26 |
27 | @Composable
28 | fun pickerSelector(
29 | enabled: Boolean = true,
30 | shape: RoundedCornerShape = RoundedCornerShape(16.dp),
31 | color: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
32 | border: BorderStroke? = null
33 | ): PickerSelector {
34 | return PickerSelector(
35 | enabled = enabled,
36 | shape = shape,
37 | color = color,
38 | border = border
39 | )
40 | }
41 |
42 | fun curveEffect(
43 | alphaEnabled: Boolean = true,
44 | minAlpha: Float = 0.2f,
45 | scaleYEnabled: Boolean = true,
46 | minScaleY: Float = 0.8f
47 | ): CurveEffect {
48 | return CurveEffect(
49 | alphaEnabled = alphaEnabled,
50 | minAlpha = minAlpha,
51 | scaleYEnabled = scaleYEnabled,
52 | minScaleY = minScaleY
53 | )
54 | }
55 |
56 | val timeFormat: TimeFormat = TimeFormat.DEFAULT
57 | val visibleItemsCount: Int = 5
58 | }
59 |
60 | @Immutable
61 | data class PickerStyle(
62 | val textStyle: TextStyle,
63 | val textColor: Color,
64 | val itemSpacing: Dp
65 | )
66 |
67 | @Immutable
68 | data class CurveEffect(
69 | val alphaEnabled: Boolean = true,
70 | val minAlpha: Float = 0.2f,
71 | val scaleYEnabled: Boolean = true,
72 | val minScaleY: Float = 0.8f
73 | ) {
74 | fun calculateAlpha(distanceFromCenter: Float, maxDistance: Float): Float {
75 | if (!alphaEnabled) return 1f
76 | val ratio = (distanceFromCenter / maxDistance).coerceIn(0f, 1f)
77 | return ((1f - ratio) * (1f - minAlpha) + minAlpha)
78 | }
79 |
80 | fun calculateScaleY(distanceFromCenter: Float, maxDistance: Float): Float {
81 | if (!scaleYEnabled) return 1f
82 | val ratio = (distanceFromCenter / maxDistance).coerceIn(0f, 1f)
83 | return ((1f - ratio) * (1f - minScaleY) + minScaleY)
84 | }
85 | }
86 |
87 | @Immutable
88 | data class PickerSelector(
89 | val enabled: Boolean,
90 | val shape: RoundedCornerShape,
91 | val color: Color,
92 | val border: BorderStroke?
93 | )
94 |
95 | enum class TimeFormat(val is24Hour: Boolean, val localeTimeFormat: LocaleTimeFormat) {
96 | DEFAULT(false, LocaleTimeFormat.ENGLISH),
97 | TWELVE_HOUR(false, LocaleTimeFormat.ENGLISH),
98 | TWELVE_HOUR_KOREAN(false, LocaleTimeFormat.KOREAN),
99 | TWENTY_FOUR_HOUR(true, LocaleTimeFormat.ENGLISH);
100 | }
101 |
102 | enum class LocaleTimeFormat {
103 | ENGLISH, KOREAN
104 | }
105 |
106 | enum class TimePeriod(private val englishLabel: String, private val koreanLabel: String) {
107 | AM("AM", "오전"),
108 | PM("PM", "오후");
109 |
110 | fun getLabel(localeTimeFormat: LocaleTimeFormat): String {
111 | return when (localeTimeFormat) {
112 | LocaleTimeFormat.ENGLISH -> englishLabel
113 | LocaleTimeFormat.KOREAN -> koreanLabel
114 | }
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | 
3 |
4 | ## Time Picker - A customizable Jetpack Compose time picker.
5 |
6 | 
7 |
8 | A fully customizable time picker component for Jetpack Compose.
9 | It supports both 12-hour and 24-hour formats.
10 | This library allows you to configure various visual aspects like the style of hours, minutes, and AM/PM labels.
11 | It also provides full localization support and event handling for custom behaviors.
12 |
13 | 여러 커스터마이징 요소를 제공하는 Jetpack Compose를 이용한 TimePicker 컴포넌트입니다.
14 |
15 | 12-hour Format Time Picker | 24-hour Format Time Picker
16 | :-------------------------:|:-------------------------:
17 |  | 
18 |
19 | ## Features
20 |
21 | #### 1. Customizable Styling
22 | - **AM/PM Configuration**: Customize AM/PM label text style, color, and layout.
23 | - **Time Label Styling**: Adjust hour and minute text styles, colors, and item spacing via `PickerStyle`.
24 | - **Picker Selector Configuration**: Modify the appearance of the picker selector, including background color, shape, and border.
25 | - **Curve Effect**: Apply a curve effect to the picker list for a 3D cylindrical visual. Options include alpha fading and vertical scaling.
26 | - **Visible Items Count**: Control the number of visible items for compact or expansive display.
27 |
28 | #### 2. Localization Support
29 | - Supports both English (EN) and Korean (KO) locales.
30 | - Dynamically formats hours, minutes, and AM/PM labels:
31 | - EN: “PM 12:30”
32 | - KO: “오후 12:30”
33 |
34 | #### 3. Event Handling
35 | - React to time changes using `onValueChange` to update the selected time.
36 | - Implement custom behaviors on value change or selection.
37 |
38 | #### 4. Layouts
39 | - 12-hour Format Time Picker: Ideal for use in regions where 12-hour format is preferred.
40 | - 24-hour Format Time Picker: Useful in regions where 24-hour time format is standard.
41 |
42 | ## Getting Started
43 |
44 | Add the following to your build.gradle file:
45 |
46 | ```gradle
47 | // Project level build.gradle
48 | allprojects {
49 | repositories {
50 | google()
51 | mavenCentral()
52 | }
53 | }
54 |
55 | // App level build.gradle
56 | dependencies {
57 | implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
58 | implementation("io.github.dongchyeon:time-picker:")
59 | }
60 | ```
61 |
62 | ## Usage
63 |
64 | #### 12-hour Format Time Picker Example
65 |
66 | ```kotlin
67 | var selectedTime by remember { mutableStateOf(Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).time) }
68 |
69 | TimePicker(
70 | initialTime = selectedTime,
71 | timeFormat = TimeFormat.TWELVE_HOUR
72 | ) { newTime ->
73 | selectedTime = newTime
74 | }
75 | ```
76 |
77 | #### 24-hour Format Time Picker Example
78 |
79 | ```kotlin
80 | var selectedTime by remember { mutableStateOf(Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).time) }
81 |
82 | TimePicker(
83 | initialTime = selectedTime,
84 | timeFormat = TimeFormat.TWENTY_FOUR_HOUR
85 | ) { newTime ->
86 | selectedTime = newTime
87 | }
88 | ```
89 |
90 | ### Customized Options
91 |
92 | #### 1. Picker Style Configuration
93 |
94 | PickerStyle allows you to customize text styles, colors, and item spacing.
95 |
96 | ```kotlin
97 | TimePicker(
98 | // ...
99 | style = TimePickerDefaults.pickerStyle(
100 | textStyle = MaterialTheme.typography.bodyLarge,
101 | textColor = Color.White,
102 | itemSpacing = 12.dp
103 | ),
104 | // ...
105 | )
106 | ```
107 |
108 | #### 2. AM/PM Locale Configuraiton
109 |
110 | Configure the time picker to display AM/PM labels in either English or Korean.
111 |
112 | ```kotlin
113 | TimePicker(
114 | // ...
115 | timeFormat = TimeFormat.TWELVE_HOUR
116 | // ...
117 | )
118 |
119 | TimePicker(
120 | // ...
121 | timeFormat = TimeFormat.TWELVE_HOUR_KOREAN
122 | // ...
123 | )
124 | ```
125 |
126 | #### 3. Picker Selector Configuration
127 |
128 | Modify selector color, shape, and border.
129 |
130 | ```kotlin
131 | TimePicker(
132 | // ...
133 | selector = TimePickerDefaults.pickerSelector(
134 | color = Color.Gray.copy(alpha = 0.4f),
135 | shape = RoundedCornerShape(16.dp),
136 | border = BorderStroke(1.dp, Color.Gray)
137 | )
138 | // ...
139 | )
140 | ```
141 |
142 | #### 4. Visible Items Count Configuration
143 |
144 | Control how many items are shown in the picker at once.
145 |
146 | ```kotlin
147 | TimePicker(
148 | // ...
149 | visibleItemsCount = 7
150 | // ...
151 | )
152 | ```
153 |
154 | #### 5. Curve Effect Configuration
155 |
156 | Apply a curve effect to the picker for a 3D cylindrical visual.
157 | Options include:
158 | - Enable/disable alpha fading (alphaEnabled, minAlpha)
159 | - Enable/disable vertical scaling (scaleYEnabled, minScaleY)
160 |
161 | ```kotlin
162 | TimePicker(
163 | // ...
164 | curveEffect = TimePickerDefaults.curveEffect(
165 | alphaEnabled = true,
166 | minAlpha = 0.3f,
167 | scaleYEnabled = true,
168 | minScaleY = 0.85f
169 | )
170 | // ...
171 | )
172 | ```
173 |
174 | ## License
175 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
176 |
177 | ### Contributions, bug reports, and feature requests are welcome!
178 |
--------------------------------------------------------------------------------
/app/src/main/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 |
171 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or 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 UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/timepicker/src/main/java/com/dongchyeon/timepicker/ui/PickerItem.kt:
--------------------------------------------------------------------------------
1 | package com.dongchyeon.timepicker.ui
2 |
3 | import androidx.compose.foundation.gestures.detectVerticalDragGestures
4 | import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.height
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.lazy.LazyColumn
10 | import androidx.compose.foundation.lazy.LazyListItemInfo
11 | import androidx.compose.material3.Text
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.runtime.LaunchedEffect
14 | import androidx.compose.runtime.getValue
15 | import androidx.compose.runtime.mutableIntStateOf
16 | import androidx.compose.runtime.remember
17 | import androidx.compose.runtime.rememberUpdatedState
18 | import androidx.compose.runtime.setValue
19 | import androidx.compose.runtime.snapshotFlow
20 | import androidx.compose.ui.Alignment
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.graphics.graphicsLayer
23 | import androidx.compose.ui.input.pointer.pointerInput
24 | import androidx.compose.ui.layout.onSizeChanged
25 | import androidx.compose.ui.platform.LocalDensity
26 | import androidx.compose.ui.tooling.preview.Preview
27 | import com.dongchyeon.timepicker.CurveEffect
28 | import com.dongchyeon.timepicker.PickerStyle
29 | import com.dongchyeon.timepicker.TimePickerDefaults
30 | import com.dongchyeon.timepicker.model.PickerState
31 | import com.dongchyeon.timepicker.model.rememberPickerState
32 | import com.dongchyeon.timepicker.ui.util.toPx
33 | import kotlinx.coroutines.flow.distinctUntilChanged
34 | import kotlinx.coroutines.flow.map
35 | import kotlin.math.abs
36 |
37 | @Composable
38 | internal fun PickerItem(
39 | modifier: Modifier = Modifier,
40 | items: List,
41 | state: PickerState = rememberPickerState(items = items),
42 | visibleItemsCount: Int,
43 | style: PickerStyle,
44 | textModifier: Modifier = Modifier,
45 | itemFormatter: (T) -> String = { it.toString() },
46 | infiniteScroll: Boolean,
47 | curveEffect: CurveEffect,
48 | onValueChange: (T) -> Unit
49 | ) {
50 | val visibleItemsMiddle = visibleItemsCount / 2
51 | val listScrollCount = if (infiniteScroll) Int.MAX_VALUE else items.size + visibleItemsMiddle * 2
52 | val listScrollMiddle = listScrollCount / 2
53 |
54 | val listState = state.lazyListState
55 | val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState)
56 | var itemHeightPixels by remember { mutableIntStateOf(0) }
57 | val itemHeightDp = with(LocalDensity.current) { itemHeightPixels.toDp() }
58 |
59 | LaunchedEffect(state.initialIndex) {
60 | val safeStartIndex = state.initialIndex
61 | val listStartIndex = if (infiniteScroll) {
62 | getStartIndexForInfiniteScroll(itemHeightPixels, listScrollMiddle, visibleItemsMiddle, safeStartIndex)
63 | } else {
64 | safeStartIndex
65 | }
66 | listState.scrollToItem(listStartIndex, 0)
67 |
68 | if (!infiniteScroll) {
69 | val selectedItem = items.getOrNull(listStartIndex) ?: items.first()
70 | if (listStartIndex != state.selectedIndex.value) {
71 | state.updateSelectedIndex(listStartIndex)
72 | }
73 | onValueChange(selectedItem)
74 | }
75 | }
76 |
77 | LaunchedEffect(listState) {
78 | snapshotFlow { listState.layoutInfo }
79 | .map { layoutInfo ->
80 | val centerOffset = layoutInfo.viewportStartOffset +
81 | (layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset) / 2
82 | layoutInfo.visibleItemsInfo.minByOrNull { item ->
83 | val itemCenter = item.offset + (item.size / 2)
84 | abs(itemCenter - centerOffset)
85 | }?.index
86 | }
87 | .map { centerIndex ->
88 | centerIndex?.let { index ->
89 | if (infiniteScroll) {
90 | index % items.size
91 | } else {
92 | (index - visibleItemsMiddle).coerceIn(0, items.size - 1)
93 | }
94 | }
95 | }
96 | .distinctUntilChanged()
97 | .collect { adjustedIndex ->
98 | if (adjustedIndex != null && adjustedIndex != state.selectedIndex.value) {
99 | state.updateSelectedIndex(adjustedIndex)
100 | onValueChange(items[adjustedIndex])
101 | }
102 | }
103 | }
104 |
105 | val totalItemHeight = itemHeightDp + style.itemSpacing
106 | val totalItemHeightPx = totalItemHeight.toPx()
107 |
108 | val layoutInfo by rememberUpdatedState(listState.layoutInfo)
109 |
110 | val itemInfoMap = remember(layoutInfo) {
111 | layoutInfo.visibleItemsInfo.associateBy { it.index }
112 | }
113 |
114 | val viewportCenterOffset = layoutInfo.viewportStartOffset + (layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset) / 2
115 |
116 | Box(modifier = modifier) {
117 | LazyColumn(
118 | state = listState,
119 | flingBehavior = flingBehavior,
120 | horizontalAlignment = Alignment.CenterHorizontally,
121 | modifier = Modifier
122 | .fillMaxWidth()
123 | .height(totalItemHeight * visibleItemsCount)
124 | .pointerInput(Unit) { detectVerticalDragGestures { change, _ -> change.consume() } }
125 | ) {
126 | items(listScrollCount, key = { index -> index }) { index ->
127 | val item = getItemForIndex(
128 | index = index,
129 | items = items,
130 | infiniteScroll = infiniteScroll,
131 | visibleItemsMiddle = visibleItemsMiddle
132 | )
133 |
134 | Text(
135 | text = item?.let { itemFormatter(it) } ?: "",
136 | maxLines = 1,
137 | style = style.textStyle,
138 | color = style.textColor,
139 | modifier = Modifier
140 | .padding(vertical = style.itemSpacing / 2)
141 | .curvedPickerEffect(
142 | index = index,
143 | viewportCenterOffset = viewportCenterOffset,
144 | itemInfoMap = itemInfoMap,
145 | totalItemHeightPx = totalItemHeightPx,
146 | visibleItemsMiddle = visibleItemsMiddle,
147 | curveEffect = curveEffect
148 | ).onSizeChanged { size -> itemHeightPixels = size.height }
149 | .then(textModifier)
150 | )
151 | }
152 | }
153 | }
154 | }
155 |
156 | private fun getItemForIndex(
157 | index: Int,
158 | items: List,
159 | infiniteScroll: Boolean,
160 | visibleItemsMiddle: Int
161 | ): T? {
162 | require(items.isNotEmpty()) { "Items list cannot be empty." }
163 |
164 | return if (!infiniteScroll) {
165 | items.getOrNull(index - visibleItemsMiddle)
166 | } else {
167 | items.getOrNull(index % items.size)
168 | }
169 | }
170 |
171 | private fun getStartIndexForInfiniteScroll(
172 | itemSize: Int,
173 | listScrollMiddle: Int,
174 | visibleItemsMiddle: Int,
175 | startIndex: Int
176 | ): Int {
177 | if (itemSize == 0) {
178 | return listScrollMiddle - visibleItemsMiddle + startIndex
179 | }
180 |
181 | return listScrollMiddle - listScrollMiddle % itemSize - visibleItemsMiddle + startIndex
182 | }
183 |
184 | fun Modifier.curvedPickerEffect(
185 | index: Int,
186 | viewportCenterOffset: Int,
187 | itemInfoMap: Map,
188 | totalItemHeightPx: Float,
189 | visibleItemsMiddle: Int,
190 | curveEffect: CurveEffect
191 | ): Modifier = graphicsLayer {
192 | val itemInfo = itemInfoMap[index]
193 | val itemCenterOffset = itemInfo?.let { it.offset + (it.size / 2) } ?: 0
194 |
195 | val distanceFromCenter = abs(viewportCenterOffset - itemCenterOffset).toFloat()
196 | val maxDistance = totalItemHeightPx * visibleItemsMiddle
197 |
198 | val alpha = curveEffect.calculateAlpha(distanceFromCenter, maxDistance)
199 | val scaleY = curveEffect.calculateScaleY(distanceFromCenter, maxDistance)
200 |
201 | this.alpha = alpha
202 | this.scaleY = scaleY
203 | }
204 |
205 | @Composable
206 | @Preview
207 | private fun PickerItemPreview() {
208 | PickerItem(
209 | items = (0..100).map { it },
210 | visibleItemsCount = 5,
211 | style = TimePickerDefaults.pickerStyle(),
212 | curveEffect = TimePickerDefaults.curveEffect(),
213 | infiniteScroll = true,
214 | onValueChange = {}
215 | )
216 | }
217 |
--------------------------------------------------------------------------------
/timepicker/src/main/java/com/dongchyeon/timepicker/TimePicker.kt:
--------------------------------------------------------------------------------
1 | package com.dongchyeon.timepicker
2 |
3 | import android.util.Log
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.border
6 | import androidx.compose.foundation.layout.Arrangement
7 | import androidx.compose.foundation.layout.Box
8 | import androidx.compose.foundation.layout.Column
9 | import androidx.compose.foundation.layout.Row
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.height
12 | import androidx.compose.foundation.layout.padding
13 | import androidx.compose.foundation.shape.RoundedCornerShape
14 | import androidx.compose.material3.Text
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.runtime.getValue
17 | import androidx.compose.runtime.mutableIntStateOf
18 | import androidx.compose.runtime.remember
19 | import androidx.compose.runtime.rememberCoroutineScope
20 | import androidx.compose.runtime.setValue
21 | import androidx.compose.ui.Alignment
22 | import androidx.compose.ui.Modifier
23 | import androidx.compose.ui.graphics.Color
24 | import androidx.compose.ui.platform.LocalDensity
25 | import androidx.compose.ui.tooling.preview.Preview
26 | import androidx.compose.ui.unit.dp
27 | import com.dongchyeon.timepicker.model.PickerState
28 | import com.dongchyeon.timepicker.model.rememberPickerState
29 | import com.dongchyeon.timepicker.ui.PickerItem
30 | import kotlinx.coroutines.launch
31 | import kotlinx.datetime.Clock
32 | import kotlinx.datetime.LocalTime
33 | import kotlinx.datetime.TimeZone
34 | import kotlinx.datetime.toLocalDateTime
35 |
36 | @Composable
37 | fun TimePicker(
38 | modifier: Modifier = Modifier,
39 | initialTime: LocalTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).time,
40 | visibleItemsCount: Int = TimePickerDefaults.visibleItemsCount,
41 | timeFormat: TimeFormat = TimePickerDefaults.timeFormat,
42 | style: PickerStyle = TimePickerDefaults.pickerStyle(),
43 | selector: PickerSelector = TimePickerDefaults.pickerSelector(),
44 | curveEffect: CurveEffect = TimePickerDefaults.curveEffect(),
45 | onValueChange: (LocalTime) -> Unit
46 | ) {
47 | if (timeFormat.is24Hour) {
48 | TimePicker24Hour(
49 | modifier = modifier,
50 | visibleItemsCount = visibleItemsCount,
51 | initialTime = initialTime,
52 | style = style,
53 | selector = selector,
54 | curveEffect = curveEffect,
55 | onValueChange = onValueChange
56 | )
57 | } else {
58 | TimePicker12Hour(
59 | modifier = modifier,
60 | visibleItemsCount = visibleItemsCount,
61 | initialTime = initialTime,
62 | localeTimeFormat = timeFormat.localeTimeFormat,
63 | style = style,
64 | selector = selector,
65 | curveEffect = curveEffect,
66 | onValueChange = onValueChange
67 | )
68 | }
69 | }
70 |
71 | @Composable
72 | private fun TimePicker12Hour(
73 | modifier: Modifier = Modifier,
74 | initialTime: LocalTime,
75 | visibleItemsCount: Int,
76 | localeTimeFormat: LocaleTimeFormat,
77 | style: PickerStyle,
78 | selector: PickerSelector,
79 | curveEffect: CurveEffect,
80 | onValueChange: (LocalTime) -> Unit
81 | ) {
82 | val amPmItems = remember {
83 | listOf(
84 | TimePeriod.AM.getLabel(localeTimeFormat),
85 | TimePeriod.PM.getLabel(localeTimeFormat)
86 | )
87 | }
88 | val hourItems = remember { (1..12).toList() }
89 | val minuteItems = remember { (0..59).toList() }
90 |
91 | val amPmPickerState = rememberPickerState(
92 | initialIndex = if (initialTime.hour < 12) 0 else 1,
93 | items = amPmItems
94 | )
95 | val hourPickerState = rememberPickerState(
96 | initialIndex = hourItems.indexOf(if (initialTime.hour % 12 == 0) 12 else initialTime.hour % 12),
97 | items = hourItems
98 | )
99 | val minutePickerState = rememberPickerState(
100 | initialIndex = minuteItems.indexOf(initialTime.minute),
101 | items = minuteItems
102 | )
103 |
104 | var previousHour by remember { mutableIntStateOf(initialTime.hour) }
105 | val scope = rememberCoroutineScope()
106 |
107 | Box(
108 | modifier = modifier,
109 | contentAlignment = Alignment.Center
110 | ) {
111 | SelectorBackground(
112 | style = style,
113 | selector = selector
114 | )
115 |
116 | Row(
117 | modifier = Modifier.padding(horizontal = 50.dp),
118 | verticalAlignment = Alignment.CenterVertically
119 | ) {
120 | PickerItem(
121 | items = amPmItems,
122 | state = amPmPickerState,
123 | visibleItemsCount = visibleItemsCount,
124 | style = style,
125 | modifier = Modifier.weight(1f),
126 | textModifier = Modifier.padding(8.dp),
127 | infiniteScroll = false,
128 | curveEffect = curveEffect,
129 | onValueChange = {
130 | onPickerValueChange(
131 | amPmPickerState,
132 | hourPickerState,
133 | minutePickerState,
134 | localeTimeFormat,
135 | onValueChange
136 | )
137 | }
138 | )
139 |
140 | PickerItem(
141 | items = hourItems,
142 | state = hourPickerState,
143 | visibleItemsCount = visibleItemsCount,
144 | style = style,
145 | modifier = Modifier.weight(1f),
146 | textModifier = Modifier.padding(8.dp),
147 | infiniteScroll = true,
148 | curveEffect = curveEffect,
149 | onValueChange = {
150 | onPickerValueChange(
151 | amPmPickerState,
152 | hourPickerState,
153 | minutePickerState,
154 | localeTimeFormat,
155 | onValueChange
156 | )
157 | scope.launch {
158 | val currentHour = hourPickerState.selectedItem
159 | val currentIndex = amPmPickerState.lazyListState.firstVisibleItemIndex % amPmItems.size
160 | val nextIndex = (currentIndex + 1) % amPmItems.size
161 |
162 | if ((currentHour == 12 && previousHour == 11) ||
163 | (currentHour == 11 && previousHour == 12)
164 | ) {
165 | amPmPickerState.lazyListState.animateScrollToItem(nextIndex)
166 | }
167 | previousHour = currentHour
168 | }
169 | }
170 | )
171 |
172 | PickerItem(
173 | items = minuteItems,
174 | state = minutePickerState,
175 | visibleItemsCount = visibleItemsCount,
176 | style = style,
177 | modifier = Modifier.weight(1f),
178 | textModifier = Modifier.padding(8.dp),
179 | infiniteScroll = true,
180 | itemFormatter = { it.toString().padStart(2, '0') },
181 | curveEffect = curveEffect,
182 | onValueChange = {
183 | onPickerValueChange(
184 | amPmPickerState,
185 | hourPickerState,
186 | minutePickerState,
187 | localeTimeFormat,
188 | onValueChange
189 | )
190 | }
191 | )
192 | }
193 | }
194 | }
195 |
196 | @Composable
197 | private fun SelectorBackground(
198 | modifier: Modifier = Modifier,
199 | style: PickerStyle,
200 | selector: PickerSelector
201 | ) {
202 | Box(
203 | modifier = modifier
204 | .fillMaxWidth()
205 | .padding(horizontal = 20.dp)
206 | .height(with(LocalDensity.current) { style.textStyle.lineHeight.toDp() } + 20.dp)
207 | .background(color = selector.color, shape = selector.shape)
208 | .then(
209 | if (selector.border != null) {
210 | Modifier.border(border = selector.border, shape = selector.shape)
211 | } else {
212 | Modifier
213 | }
214 | )
215 | )
216 | }
217 |
218 | @Composable
219 | private fun TimePicker24Hour(
220 | modifier: Modifier = Modifier,
221 | initialTime: LocalTime,
222 | visibleItemsCount: Int,
223 | style: PickerStyle,
224 | selector: PickerSelector,
225 | curveEffect: CurveEffect,
226 | onValueChange: (LocalTime) -> Unit
227 | ) {
228 | val hourItems = remember { (0..23).toList() }
229 | val minuteItems = remember { (0..59).toList() }
230 |
231 | val hourPickerState = rememberPickerState(
232 | initialIndex = hourItems.indexOf(initialTime.hour),
233 | items = hourItems
234 | )
235 | val minutePickerState = rememberPickerState(
236 | initialIndex = minuteItems.indexOf(initialTime.minute),
237 | items = minuteItems
238 | )
239 |
240 | Box(
241 | modifier = modifier,
242 | contentAlignment = Alignment.Center
243 | ) {
244 | SelectorBackground(
245 | style = style,
246 | selector = selector
247 | )
248 |
249 | Row(
250 | modifier = Modifier
251 | .fillMaxWidth()
252 | .padding(horizontal = 50.dp),
253 | verticalAlignment = Alignment.CenterVertically
254 | ) {
255 | PickerItem(
256 | items = hourItems,
257 | state = hourPickerState,
258 | visibleItemsCount = visibleItemsCount,
259 | style = style,
260 | modifier = Modifier.weight(1f),
261 | textModifier = Modifier.padding(8.dp),
262 | infiniteScroll = true,
263 | curveEffect = curveEffect,
264 | onValueChange = {
265 | onPickerValueChange(hourPickerState, minutePickerState, onValueChange)
266 | }
267 | )
268 |
269 | Text(
270 | text = ":",
271 | style = style.textStyle,
272 | color = style.textColor
273 | )
274 |
275 | PickerItem(
276 | items = minuteItems,
277 | state = minutePickerState,
278 | visibleItemsCount = visibleItemsCount,
279 | style = style,
280 | modifier = Modifier.weight(1f),
281 | textModifier = Modifier.padding(8.dp),
282 | infiniteScroll = true,
283 | itemFormatter = { it.toString().padStart(2, '0') },
284 | curveEffect = curveEffect,
285 | onValueChange = {
286 | onPickerValueChange(hourPickerState, minutePickerState, onValueChange)
287 | }
288 | )
289 | }
290 | }
291 | }
292 |
293 | private fun onPickerValueChange(
294 | amPmState: PickerState,
295 | hourState: PickerState,
296 | minuteState: PickerState,
297 | localeTimeFormat: LocaleTimeFormat,
298 | onValueChange: (LocalTime) -> Unit
299 | ) {
300 | val amPm = amPmState.selectedItem
301 | val hour = hourState.selectedItem
302 | val minute = minuteState.selectedItem
303 |
304 | val adjustedHour = when (localeTimeFormat) {
305 | LocaleTimeFormat.ENGLISH -> {
306 | if (amPm == TimePeriod.AM.getLabel(LocaleTimeFormat.ENGLISH) && hour == 12) {
307 | 0
308 | } else if (amPm == TimePeriod.PM.getLabel(LocaleTimeFormat.ENGLISH) && hour != 12) {
309 | hour + 12
310 | } else {
311 | hour
312 | }
313 | }
314 | LocaleTimeFormat.KOREAN -> {
315 | if (amPm == TimePeriod.AM.getLabel(LocaleTimeFormat.KOREAN) && hour == 12) {
316 | 0
317 | } else if (amPm == TimePeriod.PM.getLabel(LocaleTimeFormat.KOREAN) && hour != 12) {
318 | hour + 12
319 | } else {
320 | hour
321 | }
322 | }
323 | }
324 |
325 | val newTime = LocalTime(adjustedHour, minute)
326 |
327 | onValueChange(newTime)
328 | }
329 |
330 | private fun onPickerValueChange(
331 | hourState: PickerState,
332 | minuteState: PickerState,
333 | onValueChange: (LocalTime) -> Unit
334 | ) {
335 | val hour = hourState.selectedItem
336 | val minute = minuteState.selectedItem
337 |
338 | val newTime = LocalTime(hour, minute)
339 |
340 | onValueChange(newTime)
341 | }
342 |
343 | @Preview
344 | @Composable
345 | private fun TimePickerPreview() {
346 | Column(
347 | modifier = Modifier
348 | .background(Color.Black),
349 | verticalArrangement = Arrangement.spacedBy(20.dp)
350 | ) {
351 | TimePicker(
352 | timeFormat = TimeFormat.TWENTY_FOUR_HOUR
353 | ) { newTime ->
354 | Log.d("TimePicker", "Selected Time: $newTime")
355 | }
356 |
357 | TimePicker(
358 | selector = TimePickerDefaults.pickerSelector(
359 | color = Color.Gray.copy(alpha = 0.4f),
360 | shape = RoundedCornerShape(16.dp)
361 | ),
362 | visibleItemsCount = 7,
363 | timeFormat = TimeFormat.TWELVE_HOUR_KOREAN
364 | ) { newTime ->
365 | Log.d("TimePicker", "Selected Time: $newTime")
366 | }
367 | }
368 | }
369 |
--------------------------------------------------------------------------------