├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── values │ │ │ │ ├── strings.xml │ │ │ │ ├── colors.xml │ │ │ │ └── themes.xml │ │ │ ├── font │ │ │ │ └── rubik_regular.ttf │ │ │ ├── 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 │ │ │ ├── drawable │ │ │ │ ├── ic_baseline_zoom_out_24.xml │ │ │ │ ├── ic_baseline_zoom_in_24.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ ├── values-night │ │ │ │ └── themes.xml │ │ │ ├── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ │ └── layout │ │ │ │ └── activity_main.xml │ │ ├── AndroidManifest.xml │ │ └── java │ │ │ └── com │ │ │ └── mcxinyu │ │ │ └── scheduletimerulerdemo │ │ │ └── MainActivity.kt │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── mcxinyu │ │ │ └── scheduletimeruler │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── mcxinyu │ │ └── scheduletimeruler │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle ├── core ├── .gitignore ├── consumer-rules.pro ├── src │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── mcxinyu │ │ │ │ └── scheduletimeruler │ │ │ │ ├── Constract.kt │ │ │ │ ├── utils │ │ │ │ └── Dimension.kt │ │ │ │ ├── model │ │ │ │ ├── TimeModel.kt │ │ │ │ └── CardModel.kt │ │ │ │ ├── ScaleTimeRulerView.kt │ │ │ │ ├── ScheduleTimeRulerView.kt │ │ │ │ └── TimeRulerView.kt │ │ └── res │ │ │ └── values │ │ │ ├── colors.xml │ │ │ └── attrs.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── mcxinyu │ │ │ └── scheduletimeruler │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── mcxinyu │ │ └── scheduletimeruler │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle ├── art ├── hor.png ├── img.png └── ver.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── settings.gradle ├── LICENSE ├── gradle.properties ├── .github └── workflows │ └── android.yml ├── readme.md ├── gradlew.bat └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /art/hor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcxinyu/ScheduleTimeRuler/HEAD/art/hor.png -------------------------------------------------------------------------------- /art/img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcxinyu/ScheduleTimeRuler/HEAD/art/img.png -------------------------------------------------------------------------------- /art/ver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcxinyu/ScheduleTimeRuler/HEAD/art/ver.png -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | ScheduleTimeRuler 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcxinyu/ScheduleTimeRuler/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/font/rubik_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcxinyu/ScheduleTimeRuler/HEAD/app/src/main/res/font/rubik_regular.ttf -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcxinyu/ScheduleTimeRuler/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcxinyu/ScheduleTimeRuler/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcxinyu/ScheduleTimeRuler/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcxinyu/ScheduleTimeRuler/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcxinyu/ScheduleTimeRuler/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcxinyu/ScheduleTimeRuler/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/mcxinyu/ScheduleTimeRuler/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/mcxinyu/ScheduleTimeRuler/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/mcxinyu/ScheduleTimeRuler/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcxinyu/ScheduleTimeRuler/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /core/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Dec 24 14:05:20 CST 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 3 | repositories { 4 | maven { url 'https://jitpack.io' } 5 | google() 6 | mavenCentral() 7 | jcenter() // Warning: this repository is going to shut down soon 8 | } 9 | } 10 | rootProject.name = "ScheduleTimeRuler" 11 | include ':app' 12 | include ':core' 13 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/test/java/com/mcxinyu/scheduletimeruler/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.mcxinyu.scheduletimeruler 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /core/src/test/java/com/mcxinyu/scheduletimeruler/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.mcxinyu.scheduletimeruler 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /core/src/main/java/com/mcxinyu/scheduletimeruler/Constract.kt: -------------------------------------------------------------------------------- 1 | package com.mcxinyu.scheduletimeruler 2 | 3 | import android.annotation.SuppressLint 4 | import java.text.SimpleDateFormat 5 | 6 | @SuppressLint("SimpleDateFormat") 7 | val simpleDateFormat = SimpleDateFormat("HH:mm") 8 | 9 | @SuppressLint("SimpleDateFormat") 10 | val simpleDateFormat2 = SimpleDateFormat("HH:mm:ss") 11 | 12 | @SuppressLint("SimpleDateFormat") 13 | val simpleDateFormat3 = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") 14 | -------------------------------------------------------------------------------- /core/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | 11 | #FF888888 12 | #FFCCCCCC 13 | -------------------------------------------------------------------------------- /core/src/main/java/com/mcxinyu/scheduletimeruler/utils/Dimension.kt: -------------------------------------------------------------------------------- 1 | package com.mcxinyu.scheduletimeruler 2 | 3 | import android.content.Context 4 | import android.util.TypedValue 5 | 6 | fun Float.toPx(context: Context): Float = 7 | TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this, context.resources.displayMetrics) 8 | 9 | fun Int.toPx(context: Context): Float = this.toFloat().toPx(context) 10 | 11 | fun Float.toPxForSp(context: Context): Float = 12 | TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this, context.resources.displayMetrics) 13 | 14 | fun Int.toPxForSp(context: Context): Float = this.toFloat().toPxForSp(context) 15 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_zoom_out_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_zoom_in_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /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/mcxinyu/scheduletimeruler/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.mcxinyu.scheduletimeruler 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.mcxinyu.scheduletimeruler", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /core/src/androidTest/java/com/mcxinyu/scheduletimeruler/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.mcxinyu.scheduletimeruler 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.mcxinyu.scheduletimeruler.test", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /core/src/main/java/com/mcxinyu/scheduletimeruler/model/TimeModel.kt: -------------------------------------------------------------------------------- 1 | package com.mcxinyu.scheduletimeruler.model 2 | 3 | import java.util.* 4 | 5 | data class TimeModel( 6 | var startTimeValue: Long = Calendar.getInstance() 7 | .apply { 8 | set(Calendar.HOUR_OF_DAY, 0) 9 | set(Calendar.MINUTE, 0) 10 | set(Calendar.SECOND, 0) 11 | set(Calendar.MILLISECOND, 0) 12 | }.timeInMillis, 13 | var endTimeValue: Long = Calendar.getInstance() 14 | .apply { 15 | set(Calendar.HOUR_OF_DAY, 24) 16 | set(Calendar.MINUTE, 0) 17 | set(Calendar.SECOND, 0) 18 | set(Calendar.MILLISECOND, 0) 19 | }.timeInMillis, 20 | /** 21 | * 小格子 22 | * 普通时间点时间步长(毫秒) 23 | */ 24 | var unitTimeValue: Long = 60 * 1000, 25 | /** 26 | * 大格子 27 | * 关键时间点时间步长(毫秒) 28 | */ 29 | var keyUnitTimeValue: Long = unitTimeValue * 5, 30 | ) -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /core/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 22 | 23 | 24 | -keep public class * extends com.mcxinyu.scheduletimeruler 25 | -keepclassmembers class **$** extends com.mcxinyu.scheduletimeruler { 26 | (...); 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 YueFeng Huang 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 OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /core/src/main/java/com/mcxinyu/scheduletimeruler/model/CardModel.kt: -------------------------------------------------------------------------------- 1 | package com.mcxinyu.scheduletimeruler.model 2 | 3 | import android.graphics.Color 4 | import androidx.annotation.ColorInt 5 | import androidx.annotation.DrawableRes 6 | import com.mcxinyu.scheduletimeruler.R 7 | 8 | /** 9 | * 10 | * @author yuefeng in 2022/1/4. 11 | */ 12 | data class CardModel( 13 | var title: String = "", 14 | var text: String = "", 15 | var startTime: Long, 16 | var endTime: Long, 17 | @DrawableRes val background: Int = R.color.ltGray, 18 | @ColorInt val titleColor: Int = Color.DKGRAY, 19 | @ColorInt val textColor: Int = Color.GRAY 20 | ) { 21 | init { 22 | if (startTime > endTime) { 23 | val copy = startTime 24 | startTime = endTime 25 | endTime = copy 26 | } 27 | } 28 | } 29 | 30 | data class CardPositionInfo( 31 | val model: CardModel, 32 | var left: Float = -1f, 33 | var right: Float = -1f, 34 | var top: Float = -1f, 35 | var bottom: Float = -1f, 36 | ) { 37 | fun reset() { 38 | left = -1f 39 | right = -1f 40 | top = -1f 41 | bottom = -1f 42 | } 43 | } -------------------------------------------------------------------------------- /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 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | # trigger dependency 23 | release_status=true -------------------------------------------------------------------------------- /.github/workflows/android.yml: -------------------------------------------------------------------------------- 1 | name: Release CI 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | workflow_dispatch: 8 | branches: 9 | - 'main' 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: setup-android-tools 19 | uses: maxim-lobanov/setup-android-tools@v1 20 | with: 21 | packages: ndk;21.0.6113669 22 | - name: echo info 23 | run: echo 'https://jitpack.io/com/github/mcxinyu/ScheduleTimeRuler/${{github.ref_name}}/ScheduleTimeRuler-${{github.ref_name}}.pom' 24 | # - name: Call JitPack 25 | # uses: fjogeleit/http-request-action@v1.5.0 26 | # id: JitPack 27 | # with: 28 | # url: 'https://jitpack.io/com/github/mcxinyu/ScheduleTimeRuler/${{github.ref_name}}/ScheduleTimeRuler-${{github.ref_name}}.pom' 29 | # method: 'GET' 30 | # ignoreStatusCodes: 408 31 | # preventFailureOnNoResponse: true 32 | - uses: CamiloGarciaLaRotta/watermelon-http-client@v1 33 | id: JitPack 34 | with: 35 | url: 'https://jitpack.io/com/github/mcxinyu/ScheduleTimeRuler/${{github.ref_name}}/ScheduleTimeRuler-${{github.ref_name}}.pom' 36 | fail_fast: true 37 | - name: Show Response 38 | run: echo ${{ steps.JitPack.outputs.response }} 39 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## ScheduleTimeRulerView 2 | 3 | [![](https://jitpack.io/v/mcxinyu/ScheduleTimeRuler.svg)](https://jitpack.io/#mcxinyu/ScheduleTimeRuler) 4 | [![Release CI](https://github.com/mcxinyu/ScheduleTimeRuler/actions/workflows/android.yml/badge.svg)](https://github.com/mcxinyu/ScheduleTimeRuler/actions/workflows/android.yml) 5 | 6 | ### DEMO 7 | 8 | + 观看演示 [DEMO-VIDEO](https://www.bilibili.com/video/BV1eS4y1T7KV?share_source=copy_web) 9 | + 截图 10 | 11 | demo 12 | demo
13 | 14 | ### 使用 15 | 16 | To get a Git project into your build: 17 | 18 | Step 1. Add the JitPack repository to your build file 19 | 20 | Add it in your root build.gradle at the end of repositories: 21 | 22 | allprojects { 23 | repositories { 24 | ... 25 | maven { url 'https://jitpack.io' } 26 | } 27 | } 28 | 29 | Step 2. Add the dependency 30 | 31 | dependencies { 32 | implementation "com.github.mcxinyu:ScheduleTimeRuler:{last-version}" 33 | } 34 | 35 | ### 组件 36 | 37 | 1. 不喜欢它长得样子,支持自定义实现(你自己画哦) 38 | 39 | 2. 支持横竖轴样式了 40 | 41 | 3. 支持游标,也可通过回调自行显示 42 | 43 | 4. 支持缩放时间轴,手势或调用代码 44 | 45 | 5. 支持胶片样式的事件卡片 46 | 47 | 6. 支持背景图片的事件卡片,但还不完美 48 | 49 | 50 | 51 | #### TimeRulerView 52 | 53 | + 时间尺 54 | 55 | #### ScaleTimeRulerView 56 | 57 | + 带缩放功能的时间尺 58 | 59 | #### ScheduleTimeRulerView 60 | 61 | + 带计划事件的缩放功能的时间尺 62 | 63 | 64 | 65 | ### proguard-rules.pro 66 | 67 | 此库自带混淆规则,并自动导入,正常情况下无需手动导入。 68 | 69 | ### 参考 70 | 71 | + [TimeRuler](https://github.com/Liberations/TimeRuler) 72 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | } 5 | 6 | android { 7 | compileSdk 31 8 | 9 | defaultConfig { 10 | applicationId "com.mcxinyu.scheduletimerulerdemo" 11 | minSdk 21 12 | targetSdk 31 13 | versionCode 1 14 | versionName "1.0" 15 | 16 | // testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 17 | } 18 | 19 | buildTypes { 20 | release { 21 | minifyEnabled false 22 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 23 | } 24 | } 25 | compileOptions { 26 | sourceCompatibility JavaVersion.VERSION_11 27 | targetCompatibility JavaVersion.VERSION_11 28 | } 29 | kotlinOptions { 30 | jvmTarget = '1.8' 31 | } 32 | android.buildFeatures.viewBinding = true 33 | } 34 | 35 | dependencies { 36 | 37 | coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$desugar_jdk_libs" 38 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" 39 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" 40 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" 41 | 42 | implementation project(':core') 43 | // implementation "com.github.mcxinyu:ScheduleTimeRuler:$core_version" 44 | 45 | implementation 'androidx.core:core-ktx:1.3.2' 46 | implementation 'androidx.appcompat:appcompat:1.3.1' 47 | implementation 'com.google.android.material:material:1.3.0' 48 | implementation 'androidx.constraintlayout:constraintlayout:2.1.1' 49 | // testImplementation 'junit:junit:4.+' 50 | // androidTestImplementation 'androidx.test.ext:junit:1.1.2' 51 | // androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 52 | } -------------------------------------------------------------------------------- /core/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'kotlin-android' 4 | id 'maven-publish' 5 | } 6 | 7 | android { 8 | compileSdk 31 9 | 10 | defaultConfig { 11 | minSdk 21 12 | targetSdk 31 13 | versionCode 1 14 | versionName "1.0" 15 | 16 | // testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 17 | consumerProguardFiles "consumer-rules.pro" 18 | } 19 | 20 | buildTypes { 21 | release { 22 | minifyEnabled false 23 | // proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 24 | consumerProguardFiles 'proguard-rules.pro' 25 | } 26 | debug { 27 | consumerProguardFiles 'proguard-rules.pro' 28 | } 29 | } 30 | compileOptions { 31 | sourceCompatibility JavaVersion.VERSION_11 32 | targetCompatibility JavaVersion.VERSION_11 33 | kotlinOptions.freeCompilerArgs += ['-module-name', "com.mcxinyu.scheduletimeruler"] 34 | } 35 | kotlinOptions { 36 | jvmTarget = '1.8' 37 | } 38 | } 39 | 40 | afterEvaluate { 41 | publishing { 42 | publications { 43 | release(MavenPublication) { 44 | from components.release 45 | groupId = 'com.mcxinyu' 46 | artifactId = 'scheduletimeruler' 47 | version = core_version 48 | } 49 | } 50 | } 51 | } 52 | 53 | dependencies { 54 | 55 | implementation 'androidx.core:core-ktx:1.3.2' 56 | implementation 'androidx.appcompat:appcompat:1.3.1' 57 | implementation 'com.google.android.material:material:1.3.0' 58 | // testImplementation 'junit:junit:4.+' 59 | // androidTestImplementation 'androidx.test.ext:junit:1.1.2' 60 | // androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 61 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 17 | 18 | 27 | 28 | 40 | 41 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /core/src/main/res/values/attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /core/src/main/java/com/mcxinyu/scheduletimeruler/ScaleTimeRulerView.kt: -------------------------------------------------------------------------------- 1 | package com.mcxinyu.scheduletimeruler 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.util.AttributeSet 6 | import android.util.Log 7 | import android.view.MotionEvent 8 | import android.view.ScaleGestureDetector 9 | import androidx.annotation.FloatRange 10 | import com.mcxinyu.scheduletimeruler.model.TimeModel 11 | import kotlin.math.min 12 | 13 | /** 14 | * @author [yuefeng](mailto:mcxinyu@foxmail.com) in 2022/1/3. 15 | */ 16 | open class ScaleTimeRulerView @JvmOverloads constructor( 17 | context: Context, attrs: AttributeSet? = null 18 | ) : TimeRulerView(context, attrs), ScaleGestureDetector.OnScaleGestureListener { 19 | 20 | var scaleLevel: Level = Level.LEVEL_UNIT_1_MIN 21 | set(value) { 22 | if (field == value) { 23 | return 24 | } 25 | field = value 26 | var unitTimeValue = timeModel.unitTimeValue 27 | when (value) { 28 | Level.LEVEL_UNIT_1_MIN -> { 29 | unitTimeValue = 60 * 1000 30 | } 31 | Level.LEVEL_UNIT_5_MIN -> { 32 | unitTimeValue = 5 * 60 * 1000 33 | } 34 | Level.LEVEL_UNIT_15_MIN -> { 35 | unitTimeValue = 15 * 60 * 1000 36 | } 37 | Level.LEVEL_UNIT_30_MIN -> { 38 | unitTimeValue = 30 * 60 * 1000 39 | } 40 | Level.LEVEL_UNIT_1_HOUR -> { 41 | unitTimeValue = 60 * 60 * 1000 42 | } 43 | Level.LEVEL_UNIT_2_HOUR -> { 44 | unitTimeValue = 2 * 60 * 60 * 1000 45 | } 46 | } 47 | updateScaleInfo(unitTimeValue * 5, unitTimeValue) 48 | 49 | onScaleListener?.onScale(scaleLevel) 50 | } 51 | 52 | /** 53 | * 每毫秒最大占有像素,由[maxTickSpace]分成一分钟毫秒数得到 54 | */ 55 | protected var maxMillisecondUnitPixel: Float 56 | 57 | /** 58 | * 每毫秒最小占有像素 59 | */ 60 | protected var minMillisecondUnitPixel: Float 61 | 62 | protected var scaleRatio = 1.0f 63 | 64 | private var scaleGestureDetector = ScaleGestureDetector(context, this) 65 | 66 | init { 67 | // val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ScaleTimeRulerView) 68 | // 69 | // val level = typedArray.getInt(R.styleable.ScaleTimeRulerView_strv_scaleLevel, 1) 70 | // 71 | //// scaleLevel = when (level) { 72 | //// 1 -> Level.LEVEL_UNIT_1_MIN 73 | //// 5 -> Level.LEVEL_UNIT_5_MIN 74 | //// 15 -> Level.LEVEL_UNIT_15_MIN 75 | //// 30 -> Level.LEVEL_UNIT_30_MIN 76 | //// 60 -> Level.LEVEL_UNIT_1_HOUR 77 | //// 120 -> Level.LEVEL_UNIT_2_HOUR 78 | //// else -> Level.LEVEL_UNIT_1_MIN 79 | //// } 80 | // 81 | // typedArray.recycle() 82 | 83 | //[maxTickSpace]分成一分钟的毫秒数 84 | maxMillisecondUnitPixel = maxTickSpace / timeModel.unitTimeValue 85 | //[maxUnitPixel]缩小 360(分钟)倍 86 | minMillisecondUnitPixel = maxMillisecondUnitPixel / (60 * 3) 87 | } 88 | 89 | override fun onSizeChanged(width: Int, height: Int, oldw: Int, oldh: Int) { 90 | super.onSizeChanged(width, height, oldw, oldh) 91 | 92 | millisecondUnitPixel = maxMillisecondUnitPixel * scaleRatio 93 | tickSpacePixel = timeModel.unitTimeValue * millisecondUnitPixel 94 | } 95 | 96 | @SuppressLint("ClickableViewAccessibility") 97 | override fun onTouchEvent(event: MotionEvent): Boolean { 98 | scaleGestureDetector.onTouchEvent(event) 99 | return if (status != OnScrollListener.STATUS_ZOOM) { 100 | super.onTouchEvent(event) 101 | } else { 102 | if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { 103 | status = OnScrollListener.STATUS_IDLE 104 | } 105 | true 106 | } 107 | } 108 | 109 | override fun onScale(detector: ScaleGestureDetector): Boolean { 110 | scale(detector.scaleFactor) 111 | 112 | return millisecondUnitPixel < maxMillisecondUnitPixel || millisecondUnitPixel > minMillisecondUnitPixel 113 | } 114 | 115 | private fun scale(scaleFactor: Float) { 116 | status = OnScrollListener.STATUS_ZOOM 117 | 118 | Log.d(TAG, "scaleFactor $scaleFactor") 119 | var factor = scaleFactor 120 | 121 | millisecondUnitPixel *= factor 122 | if (millisecondUnitPixel > maxMillisecondUnitPixel) { 123 | millisecondUnitPixel = maxMillisecondUnitPixel 124 | factor = 1f 125 | } else if (millisecondUnitPixel < minMillisecondUnitPixel) { 126 | millisecondUnitPixel = minMillisecondUnitPixel 127 | factor = 1f 128 | } 129 | 130 | scaleLevel = when { 131 | maxMillisecondUnitPixel / millisecondUnitPixel > 140 -> Level.LEVEL_UNIT_2_HOUR 132 | maxMillisecondUnitPixel / millisecondUnitPixel > 70 -> Level.LEVEL_UNIT_1_HOUR 133 | maxMillisecondUnitPixel / millisecondUnitPixel > 30 -> Level.LEVEL_UNIT_30_MIN 134 | maxMillisecondUnitPixel / millisecondUnitPixel > 11 -> Level.LEVEL_UNIT_15_MIN 135 | maxMillisecondUnitPixel / millisecondUnitPixel > 3 -> Level.LEVEL_UNIT_5_MIN 136 | else -> Level.LEVEL_UNIT_1_MIN 137 | } 138 | 139 | onScale(timeModel, millisecondUnitPixel) 140 | 141 | scaleRatio *= factor 142 | 143 | tickSpacePixel = timeModel.unitTimeValue * millisecondUnitPixel 144 | 145 | invalidate() 146 | } 147 | 148 | private var lastScale = 0f 149 | fun setScale(@FloatRange(from = 0.0, to = 100.0) scale: Float) { 150 | var scaleFactor = 0.9f + scale / 1000f 151 | if (lastScale < scale) { 152 | scaleFactor = 1 + min(1000f, scale) / 1000f 153 | } 154 | scale(scaleFactor) 155 | status = OnScrollListener.STATUS_IDLE 156 | 157 | lastScale = scale 158 | } 159 | 160 | protected fun onScale(timeModel: TimeModel, unitPixel: Float) {} 161 | 162 | override fun onScaleBegin(detector: ScaleGestureDetector) = true 163 | 164 | override fun onScaleEnd(detector: ScaleGestureDetector) {} 165 | 166 | override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): 167 | Boolean { 168 | if (e2.pointerCount > 1) { 169 | return false 170 | } 171 | if (scaleGestureDetector.isInProgress) { 172 | return false 173 | } 174 | if (status == OnScrollListener.STATUS_ZOOM) { 175 | return false 176 | } 177 | 178 | return super.onScroll(e1, e2, distanceX, distanceY) 179 | } 180 | 181 | override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): 182 | Boolean { 183 | if (status == OnScrollListener.STATUS_ZOOM) { 184 | return false 185 | } 186 | return super.onFling(e1, e2, velocityX, velocityY) 187 | } 188 | 189 | /** 190 | * 缩放 191 | * 192 | * @param keyUnitTimeValue Long 193 | * @param unitTimeValue Long 194 | */ 195 | protected fun updateScaleInfo(keyUnitTimeValue: Long, unitTimeValue: Long) { 196 | timeModel.keyUnitTimeValue = keyUnitTimeValue 197 | timeModel.unitTimeValue = unitTimeValue 198 | 199 | scaleRatio = (60 * 1000f) / timeModel.unitTimeValue 200 | 201 | invalidate() 202 | } 203 | 204 | companion object { 205 | val TAG = ScaleTimeRulerView::class.java.simpleName 206 | } 207 | 208 | enum class Level { 209 | LEVEL_UNIT_1_MIN, 210 | LEVEL_UNIT_5_MIN, 211 | LEVEL_UNIT_15_MIN, 212 | LEVEL_UNIT_30_MIN, 213 | LEVEL_UNIT_1_HOUR, 214 | LEVEL_UNIT_2_HOUR, 215 | } 216 | 217 | var onScaleListener: OnScaleListener? = null 218 | 219 | interface OnScaleListener { 220 | fun onScale(level: Level) 221 | } 222 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mcxinyu/scheduletimerulerdemo/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.mcxinyu.scheduletimerulerdemo 2 | 3 | import android.animation.ValueAnimator 4 | import android.annotation.SuppressLint 5 | import android.content.res.Configuration 6 | import android.graphics.Color 7 | import android.os.Bundle 8 | import android.util.Log 9 | import android.view.ViewGroup 10 | import android.view.animation.LinearInterpolator 11 | import android.widget.SeekBar 12 | import androidx.appcompat.app.AppCompatActivity 13 | import com.mcxinyu.scheduletimeruler.ScaleTimeRulerView 14 | import com.mcxinyu.scheduletimeruler.ScheduleTimeRulerView.OnCardClickListener 15 | import com.mcxinyu.scheduletimeruler.TimeRulerView 16 | import com.mcxinyu.scheduletimeruler.model.CardModel 17 | import com.mcxinyu.scheduletimeruler.toPx 18 | import com.mcxinyu.scheduletimerulerdemo.databinding.ActivityMainBinding 19 | import java.text.SimpleDateFormat 20 | import java.util.* 21 | 22 | class MainActivity : AppCompatActivity() { 23 | private lateinit var inflate: ActivityMainBinding 24 | 25 | override fun onCreate(savedInstanceState: Bundle?) { 26 | super.onCreate(savedInstanceState) 27 | inflate = ActivityMainBinding.inflate(layoutInflater) 28 | setContentView(inflate.root) 29 | 30 | // testBase() 31 | 32 | testScale() 33 | 34 | testSchedule() 35 | } 36 | 37 | @SuppressLint("SimpleDateFormat") 38 | val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") 39 | 40 | private fun testSchedule() { 41 | val instance = Calendar.getInstance() 42 | 43 | inflate.timeRuler.setData( 44 | listOf( 45 | CardModel( 46 | "噫噫噫", 47 | "", 48 | 0, 49 | 0, 50 | background = R.drawable.ic_launcher_background 51 | ).apply { 52 | instance[Calendar.HOUR_OF_DAY] = 9 53 | instance[Calendar.MINUTE] = 20 54 | instance[Calendar.SECOND] = 40 55 | startTime = instance.timeInMillis 56 | 57 | instance[Calendar.HOUR_OF_DAY] = 11 58 | instance[Calendar.MINUTE] = 0 59 | instance[Calendar.SECOND] = 50 60 | endTime = instance.timeInMillis 61 | 62 | text = 63 | "${simpleDateFormat.format(startTime)}~${simpleDateFormat.format(endTime)}" 64 | }, 65 | CardModel("尔尔尔", "", 0, 0).apply { 66 | instance[Calendar.HOUR_OF_DAY] = 11 67 | instance[Calendar.MINUTE] = 30 68 | instance[Calendar.SECOND] = 50 69 | startTime = instance.timeInMillis 70 | 71 | instance[Calendar.HOUR_OF_DAY] = 14 72 | instance[Calendar.MINUTE] = 0 73 | instance[Calendar.SECOND] = 20 74 | endTime = instance.timeInMillis 75 | 76 | text = 77 | "尔${simpleDateFormat.format(startTime)}~${simpleDateFormat.format(endTime)}" 78 | }, 79 | CardModel("伞伞伞", "", 0, 0).apply { 80 | instance[Calendar.HOUR_OF_DAY] = 14 81 | instance[Calendar.MINUTE] = 14 82 | instance[Calendar.SECOND] = 20 83 | startTime = instance.timeInMillis 84 | 85 | instance[Calendar.HOUR_OF_DAY] = 16 86 | instance[Calendar.MINUTE] = 8 87 | instance[Calendar.SECOND] = 40 88 | endTime = instance.timeInMillis 89 | 90 | text = 91 | "${simpleDateFormat.format(startTime)}~${simpleDateFormat.format(endTime)}" 92 | }, 93 | CardModel("丝丝丝", "", 0, 0).apply { 94 | instance[Calendar.HOUR_OF_DAY] = 16 95 | instance[Calendar.MINUTE] = 48 96 | instance[Calendar.SECOND] = 40 97 | startTime = instance.timeInMillis 98 | 99 | instance[Calendar.HOUR_OF_DAY] = 17 100 | instance[Calendar.MINUTE] = 0 101 | instance[Calendar.SECOND] = 40 102 | endTime = instance.timeInMillis 103 | 104 | // text = 105 | // "${simpleDateFormat.format(startTime)}~${simpleDateFormat.format(endTime)}" 106 | }, 107 | ) 108 | ) 109 | inflate.timeRuler.onCardClickListener = object : OnCardClickListener { 110 | override fun onClick(model: CardModel) { 111 | // Toast.makeText(this@MainActivity, model.title, Toast.LENGTH_SHORT).show() 112 | 113 | // val downtime = SystemClock.uptimeMillis() 114 | // 115 | // val obtain1_0 = MotionEvent.obtain(downtime, downtime, MotionEvent.ACTION_MOVE, 0f, 0f, 0) 116 | // val obtain1_1 = MotionEvent.obtain(downtime, downtime, MotionEvent.ACTION_MOVE, 100f, 100f, 0) 117 | // inflate.timeRuler.onTouchEvent(obtain1_0) 118 | // inflate.timeRuler.onTouchEvent(obtain1_1) 119 | // obtain1_0.recycle() 120 | // obtain1_1.recycle() 121 | } 122 | } 123 | inflate.timeRuler.onScrollListener = object : TimeRulerView.OnScrollListener { 124 | override fun onScrollStateChanged(newState: Int) { 125 | Log.d(TAG, "newState $newState") 126 | } 127 | 128 | override fun onScrolled(dx: Int, dy: Int) { 129 | Log.d(TAG, "onScrolled $dx $dy") 130 | } 131 | } 132 | } 133 | 134 | private fun testScale() { 135 | inflate.timeRuler.scaleLevel = ScaleTimeRulerView.Level.LEVEL_UNIT_1_HOUR 136 | inflate.searchBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { 137 | override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { 138 | inflate.timeRuler.setScale(progress.toFloat()) 139 | } 140 | 141 | override fun onStartTrackingTouch(seekBar: SeekBar?) { 142 | } 143 | 144 | override fun onStopTrackingTouch(seekBar: SeekBar?) { 145 | } 146 | }) 147 | // inflate.timeRuler.onScrollListener = object : TimeRulerView.OnScrollListener { 148 | // override fun onScrollStateChanged(newState: Int) { 149 | // } 150 | // 151 | // override fun onScrolled(dx: Int, dy: Int) { 152 | // } 153 | // } 154 | inflate.imageViewIn.setOnClickListener { 155 | val valueAnimator: ValueAnimator = ValueAnimator.ofFloat( 156 | 1f, 157 | when (inflate.timeRuler.scaleLevel) { 158 | ScaleTimeRulerView.Level.LEVEL_UNIT_2_HOUR -> 40f 159 | ScaleTimeRulerView.Level.LEVEL_UNIT_1_HOUR -> 50f 160 | ScaleTimeRulerView.Level.LEVEL_UNIT_30_MIN -> 50f 161 | ScaleTimeRulerView.Level.LEVEL_UNIT_15_MIN -> 70f 162 | ScaleTimeRulerView.Level.LEVEL_UNIT_5_MIN -> 70f 163 | else -> 70f 164 | } 165 | ) 166 | valueAnimator.interpolator = LinearInterpolator() 167 | valueAnimator.duration = 600 168 | valueAnimator.addUpdateListener { 169 | inflate.timeRuler.setScale(it.animatedValue as Float) 170 | } 171 | valueAnimator.start() 172 | } 173 | inflate.imageViewOut.setOnClickListener { 174 | val scaleLevel = inflate.timeRuler.scaleLevel 175 | val valueAnimator: ValueAnimator = ValueAnimator.ofFloat( 176 | when (scaleLevel) { 177 | ScaleTimeRulerView.Level.LEVEL_UNIT_2_HOUR -> 100f 178 | ScaleTimeRulerView.Level.LEVEL_UNIT_1_HOUR -> 100f 179 | ScaleTimeRulerView.Level.LEVEL_UNIT_30_MIN -> 100f 180 | ScaleTimeRulerView.Level.LEVEL_UNIT_15_MIN -> 100f 181 | ScaleTimeRulerView.Level.LEVEL_UNIT_5_MIN -> 100f 182 | else -> 100f 183 | }, 184 | 1f 185 | ) 186 | valueAnimator.interpolator = LinearInterpolator() 187 | valueAnimator.duration = 600 188 | valueAnimator.addUpdateListener { 189 | inflate.timeRuler.setScale(it.animatedValue as Float) 190 | } 191 | valueAnimator.start() 192 | } 193 | } 194 | 195 | private fun testBase() { 196 | val calendar = Calendar.getInstance().apply { 197 | add(Calendar.HOUR_OF_DAY, -1) 198 | } 199 | inflate.timeRuler.cursorTimeValue = calendar.timeInMillis 200 | val start = calendar.timeInMillis 201 | calendar.add(Calendar.HOUR_OF_DAY, 6) 202 | calendar.add(Calendar.MINUTE, 15) 203 | val end = calendar.timeInMillis 204 | inflate.timeRuler.setRange(start, end) 205 | 206 | // inflate.timeRuler.onCursorListener = object : TimeRulerView.OnCursorListener { 207 | // override fun onProgressChanged(cursorTimeValue: Long) { 208 | // inflate.textViewCursor.text = simpleDateFormat2.format(cursorTimeValue) 209 | // } 210 | // } 211 | // inflate.textViewCursor.text = simpleDateFormat2.format(inflate.timeRuler.cursorTimeValue) 212 | } 213 | 214 | override fun onConfigurationChanged(newConfig: Configuration) { 215 | super.onConfigurationChanged(newConfig) 216 | 217 | if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) { 218 | inflate.timeRuler.apply { 219 | orientation = 0 220 | baselinePositionPercentage = 0.55f 221 | cursorLinePositionPercentage = 0.5f 222 | cardShowText = false 223 | cardMargin = 4.toPx(context) 224 | cardWidth = 40.toPx(context) 225 | cardFilmHoleWidth = 8.toPx(context) 226 | cardFilmHoleHeight = 4.toPx(context) 227 | cardFilmHoleGap = cardFilmHoleHeight 228 | cardFilmHoleOffset = -cardFilmHoleHeight / 2 229 | layoutParams.height = 140.toPx(context).toInt() 230 | } 231 | } else { 232 | inflate.timeRuler.apply { 233 | orientation = 1 234 | baselinePositionPercentage = 0.25f 235 | cursorLinePositionPercentage = 0.3f 236 | cardShowText = true 237 | cardMargin = 16.toPx(context) 238 | cardWidth = 128.toPx(context) 239 | cardFilmHoleWidth = 16.toPx(context) 240 | cardFilmHoleHeight = 9.toPx(context) 241 | cardFilmHoleGap = cardFilmHoleHeight 242 | cardFilmHoleOffset = -cardFilmHoleHeight / 2 243 | layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT 244 | } 245 | } 246 | } 247 | 248 | companion object { 249 | val TAG = MainActivity::class.java.simpleName 250 | } 251 | } -------------------------------------------------------------------------------- /core/src/main/java/com/mcxinyu/scheduletimeruler/ScheduleTimeRulerView.kt: -------------------------------------------------------------------------------- 1 | package com.mcxinyu.scheduletimeruler 2 | 3 | import android.content.Context 4 | import android.graphics.* 5 | import android.graphics.drawable.ColorDrawable 6 | import android.text.Layout 7 | import android.text.StaticLayout 8 | import android.text.TextPaint 9 | import android.util.AttributeSet 10 | import android.view.MotionEvent 11 | import androidx.annotation.ColorInt 12 | import androidx.core.content.res.ResourcesCompat 13 | import androidx.core.graphics.drawable.toBitmap 14 | import com.mcxinyu.scheduletimeruler.model.CardModel 15 | import com.mcxinyu.scheduletimeruler.model.CardPositionInfo 16 | import kotlin.math.abs 17 | import kotlin.math.max 18 | import kotlin.math.min 19 | 20 | /** 21 | * @author [yuefeng](mailto:mcxinyu@foxmail.com) in 2021/12/24. 22 | */ 23 | open class ScheduleTimeRulerView @JvmOverloads constructor( 24 | context: Context, attrs: AttributeSet? = null 25 | ) : ScaleTimeRulerView(context, attrs) { 26 | 27 | var cardShowText: Boolean 28 | var cardShowTitle: Boolean 29 | var cardFilmHoleOffset: Float 30 | var cardFilmHoleGap: Float 31 | var cardFilmHoleHeight: Float 32 | var cardFilmHoleWidth: Float 33 | var cardSimulateFilmStyle: Boolean 34 | 35 | @ColorInt 36 | var cardLineColor: Int 37 | var cardMargin: Float 38 | var cardWidth: Float 39 | 40 | var data = mutableListOf() 41 | internal set 42 | 43 | open fun setData(list: List) { 44 | data = list.map { CardPositionInfo(it) }.toMutableList() 45 | invalidate() 46 | } 47 | 48 | private val dp16 = 16.toPx(context) 49 | 50 | private val textPaint = TextPaint() 51 | 52 | init { 53 | val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ScheduleTimeRulerView) 54 | 55 | cardShowTitle = typedArray.getBoolean( 56 | R.styleable.ScheduleTimeRulerView_strv_cardShowTitle, 57 | true 58 | ) 59 | cardShowText = typedArray.getBoolean( 60 | R.styleable.ScheduleTimeRulerView_strv_cardShowText, 61 | true 62 | ) 63 | cardWidth = typedArray.getDimension( 64 | R.styleable.ScheduleTimeRulerView_strv_cardWidth, 65 | 128.toPx(context) 66 | ) 67 | cardMargin = typedArray.getDimension( 68 | R.styleable.ScheduleTimeRulerView_strv_cardMargin, 69 | 16.toPx(context) 70 | ) 71 | cardLineColor = typedArray.getColor( 72 | R.styleable.ScheduleTimeRulerView_strv_cardLineColor, 73 | baselineOutDayColor 74 | ) 75 | cardSimulateFilmStyle = typedArray.getBoolean( 76 | R.styleable.ScheduleTimeRulerView_strv_cardSimulateFilmStyle, 77 | true 78 | ) 79 | cardFilmHoleWidth = typedArray.getDimension( 80 | R.styleable.ScheduleTimeRulerView_strv_cardFilmHoleWidth, 81 | 16.toPx(context) 82 | ) 83 | cardFilmHoleHeight = typedArray.getDimension( 84 | R.styleable.ScheduleTimeRulerView_strv_cardFilmHoleHeight, 85 | cardFilmHoleWidth / 16 * 9 86 | ) 87 | cardFilmHoleGap = typedArray.getDimension( 88 | R.styleable.ScheduleTimeRulerView_strv_cardFilmHoleGap, 89 | cardFilmHoleHeight 90 | ) 91 | cardFilmHoleOffset = typedArray.getDimension( 92 | R.styleable.ScheduleTimeRulerView_strv_cardFilmHoleOffset, 93 | -cardFilmHoleHeight / 2 94 | ) 95 | 96 | typedArray.recycle() 97 | 98 | textPaint.textAlign = Paint.Align.LEFT 99 | typeface?.let { textPaint.typeface = typeface } 100 | } 101 | 102 | override fun onDrawTick(canvas: Canvas) { 103 | super.onDrawTick(canvas) 104 | 105 | var top = 0f 106 | var bottom = 0f 107 | var left = 0f 108 | var right = 0f 109 | 110 | if (orientation == 0) { 111 | bottom = baselinePosition - cardMargin 112 | top = bottom - cardWidth 113 | right = width.toFloat() 114 | } else { 115 | left = baselinePosition + cardMargin 116 | right = left + cardWidth 117 | bottom = height.toFloat() 118 | } 119 | 120 | textPaint.color = cardLineColor 121 | canvas.drawRect(left, top, right, bottom, textPaint) 122 | 123 | for (schedule in data) { 124 | schedule.reset() 125 | 126 | if (orientation == 0) { 127 | left = 128 | cursorLinePosition + (schedule.model.startTime - cursorTimeValue) * millisecondUnitPixel 129 | right = 130 | cursorLinePosition + (schedule.model.endTime - cursorTimeValue) * millisecondUnitPixel 131 | } else { 132 | top = 133 | cursorLinePosition + (schedule.model.startTime - cursorTimeValue) * millisecondUnitPixel 134 | bottom = 135 | cursorLinePosition + (schedule.model.endTime - cursorTimeValue) * millisecondUnitPixel 136 | } 137 | 138 | if (top > scrollY + height) { 139 | continue 140 | } 141 | if (bottom < scrollY) { 142 | continue 143 | } 144 | 145 | schedule.apply { 146 | this.left = left 147 | this.top = top 148 | this.right = right 149 | this.bottom = bottom 150 | } 151 | onDrawCard(canvas, schedule.model, left, top, right, bottom) 152 | } 153 | } 154 | 155 | private val rect = Rect() 156 | 157 | protected open fun onDrawCard( 158 | canvas: Canvas, 159 | schedule: CardModel, 160 | left: Float, top: Float, right: Float, bottom: Float 161 | ) { 162 | //region draw range 163 | val drawable = ResourcesCompat.getDrawable(resources, schedule.background, null) 164 | drawable?.let { 165 | if (it is ColorDrawable) { 166 | textPaint.color = it.color 167 | 168 | val path = Path() 169 | path.fillType = Path.FillType.EVEN_ODD 170 | path.addRect( 171 | max(left, 0f), 172 | max(top, 0f), 173 | min(right, width.toFloat()), 174 | min(bottom, height.toFloat()), 175 | Path.Direction.CW 176 | ) 177 | 178 | if (cardSimulateFilmStyle && bottom - top > cardFilmHoleHeight) { 179 | val count = 180 | (if (orientation == 0) (right - left) else (bottom - top)) / 181 | (cardFilmHoleHeight + cardFilmHoleGap - abs(cardFilmHoleOffset)) 182 | for (i in 0..count.toInt()) { 183 | if (orientation == 0) { 184 | val aLeft = 185 | left + i * cardFilmHoleWidth + cardFilmHoleOffset + i * cardFilmHoleGap 186 | val aRight = aLeft + cardFilmHoleHeight 187 | var aTop = top + cardFilmHoleGap 188 | var aBottom = aTop + cardFilmHoleWidth 189 | 190 | if (aLeft + cardFilmHoleHeight >= 0 && aRight - cardFilmHoleHeight <= width) { 191 | path.addRect( 192 | min(max(aLeft, left), right), 193 | aTop, 194 | min(aRight, right), 195 | aBottom, 196 | Path.Direction.CCW 197 | ) 198 | 199 | aBottom = bottom - cardFilmHoleGap - cardFilmHoleWidth 200 | aTop = aBottom + cardFilmHoleWidth 201 | 202 | path.addRect( 203 | min(max(aLeft, left), right), 204 | aTop, 205 | min(aRight, right), 206 | aBottom, 207 | Path.Direction.CCW 208 | ) 209 | } 210 | } else { 211 | var aLeft = left + cardFilmHoleGap 212 | var aRight = aLeft + cardFilmHoleWidth 213 | val aTop = 214 | top + i * cardFilmHoleHeight + cardFilmHoleOffset + i * cardFilmHoleGap 215 | val aBottom = aTop + cardFilmHoleHeight 216 | 217 | if (aTop + cardFilmHoleHeight >= 0 && aBottom - cardFilmHoleHeight <= height) { 218 | path.addRect( 219 | aLeft, 220 | min(max(aTop, top), bottom), 221 | aRight, 222 | min(aBottom, bottom), 223 | Path.Direction.CCW 224 | ) 225 | 226 | aLeft = right - cardFilmHoleWidth - cardFilmHoleGap 227 | aRight = aLeft + cardFilmHoleWidth 228 | 229 | path.addRect( 230 | aLeft, 231 | min(max(aTop, top), bottom), 232 | aRight, 233 | min(aBottom, bottom), 234 | Path.Direction.CCW 235 | ) 236 | } 237 | } 238 | } 239 | } 240 | 241 | canvas.drawPath(path, textPaint) 242 | } else { 243 | val toBitmap = it.toBitmap() 244 | 245 | val aWidth = min(right, width.toFloat()) - max(left, 0f) 246 | 247 | val bitmap = if (orientation == 0) 248 | Bitmap.createScaledBitmap( 249 | toBitmap, 250 | (toBitmap.width * aWidth / toBitmap.height).toInt(), 251 | (bottom - top).toInt(), 252 | true 253 | ) 254 | else 255 | Bitmap.createScaledBitmap( 256 | toBitmap, 257 | aWidth.toInt(), 258 | (aWidth * toBitmap.height / toBitmap.width).toInt(), 259 | true 260 | ) 261 | 262 | // val path = Path() 263 | // path.fillType = Path.FillType.EVEN_ODD 264 | 265 | val shader = BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT) 266 | textPaint.shader = shader 267 | 268 | canvas.drawRect( 269 | max(left, 0f), 270 | max(top, 0f), 271 | min(right, width.toFloat()), 272 | min(bottom, height.toFloat()), 273 | textPaint 274 | ) 275 | 276 | textPaint.shader = null 277 | } 278 | } 279 | //endregion 280 | 281 | val margin = dp16 / 4 282 | var titleHeight = 0 283 | 284 | textPaint.textSize = tickTextSize 285 | 286 | textPaint.getTextBounds("一", 0, 1, rect) 287 | var yiWidth = rect.width() 288 | textPaint.getTextBounds(schedule.title, 0, schedule.title.length, rect) 289 | var textWidth = rect.width() 290 | 291 | if (cardShowTitle) { 292 | textPaint.color = schedule.titleColor 293 | 294 | val titleLayout = StaticLayout( 295 | schedule.title, 296 | textPaint, 297 | max( 298 | 0f, 299 | right - left - margin * 2 - 300 | if (orientation == 1 && cardSimulateFilmStyle) cardFilmHoleGap * 2 + cardFilmHoleWidth * 2 301 | else 0f 302 | ).toInt(), 303 | Layout.Alignment.ALIGN_NORMAL, 304 | 1f, 305 | 0f, 306 | true 307 | ) 308 | 309 | //horizontal 310 | if (schedule.title.isNotEmpty() && 311 | orientation == 0 && 312 | right - left - margin * 2 > yiWidth && 313 | (bottom - top - 314 | if (cardSimulateFilmStyle) cardFilmHoleGap * 2 + cardFilmHoleWidth * 2 315 | else 0f) >= titleLayout.height 316 | ) { 317 | titleHeight = titleLayout.height 318 | 319 | val aWidth = min(textWidth, titleLayout.width) + margin * 2 320 | 321 | canvas.save() 322 | canvas.translate( 323 | if (aWidth >= right) right - aWidth 324 | else max(0f, left) + margin, 325 | top + 326 | if (cardSimulateFilmStyle) cardFilmHoleGap + cardFilmHoleWidth 327 | else 0f 328 | ) 329 | titleLayout.draw(canvas) 330 | canvas.restore() 331 | } 332 | //vertical 333 | if (schedule.title.isNotEmpty() && orientation == 1 && bottom - top >= titleLayout.height) { 334 | titleHeight = titleLayout.height 335 | 336 | canvas.save() 337 | canvas.translate( 338 | left + margin + 339 | if (cardSimulateFilmStyle) cardFilmHoleGap + cardFilmHoleWidth 340 | else 0f, 341 | if (bottom - max(0f, top) >= titleLayout.height + margin * 2) 342 | max(0f, top) + margin 343 | else 344 | bottom - titleLayout.height - margin 345 | ) 346 | titleLayout.draw(canvas) 347 | canvas.restore() 348 | } 349 | } 350 | 351 | //region draw text 352 | if (cardShowText) { 353 | textPaint.textSize = tickTextSize * 0.9f 354 | textPaint.color = schedule.textColor 355 | 356 | textPaint.getTextBounds("一", 0, 1, rect) 357 | yiWidth = rect.width() 358 | textPaint.getTextBounds(schedule.text, 0, schedule.text.length, rect) 359 | textWidth = rect.width() 360 | 361 | val textLayout = StaticLayout( 362 | schedule.text, 363 | textPaint, 364 | max( 365 | 0f, 366 | min(right, right - left) - margin * 2 - 367 | if (orientation == 1 && cardSimulateFilmStyle) cardFilmHoleGap * 2 + cardFilmHoleWidth * 2 368 | else 0f 369 | ).toInt(), 370 | Layout.Alignment.ALIGN_NORMAL, 371 | 1f, 372 | 0f, 373 | true 374 | ) 375 | //horizontal 376 | if (schedule.text.isNotEmpty() && 377 | orientation == 0 && 378 | right - left - margin * 2 > yiWidth && 379 | (bottom - top - margin * 2 - titleHeight - 380 | if (cardSimulateFilmStyle) cardFilmHoleGap * 2 + cardFilmHoleWidth * 2 381 | else 0f) >= textLayout.height 382 | ) { 383 | val aWidth = min(textWidth, textLayout.width) + margin * 2 384 | 385 | canvas.save() 386 | canvas.translate( 387 | if (aWidth >= right) right - aWidth 388 | else max(0f, left) + margin, 389 | top + titleHeight + 390 | if (cardSimulateFilmStyle) cardFilmHoleGap + cardFilmHoleWidth 391 | else 0f 392 | ) 393 | textLayout.draw(canvas) 394 | canvas.restore() 395 | } 396 | //vertical 397 | if (schedule.text.isNotEmpty() && 398 | orientation == 1 && 399 | bottom - max(0f, top) >= titleHeight + textLayout.height 400 | ) { 401 | canvas.save() 402 | canvas.translate( 403 | left + margin + 404 | if (cardSimulateFilmStyle) cardFilmHoleGap + cardFilmHoleWidth 405 | else 0f, 406 | max(0f, top) + titleHeight + margin 407 | ) 408 | textLayout.draw(canvas) 409 | canvas.restore() 410 | } 411 | } 412 | //endregion 413 | } 414 | 415 | override fun onSingleTapUp(e: MotionEvent): Boolean { 416 | onCardClickListener?.let { listener -> 417 | data.firstOrNull { 418 | it.left < e.x && it.right > e.x && it.top < e.y && it.bottom > e.y 419 | }?.let { 420 | listener.onClick(it.model) 421 | } 422 | } 423 | 424 | return super.onSingleTapUp(e) 425 | } 426 | 427 | var onCardClickListener: OnCardClickListener? = null 428 | 429 | interface OnCardClickListener { 430 | fun onClick(model: CardModel) 431 | } 432 | 433 | companion object { 434 | val TAG = ScheduleTimeRulerView::class.java.simpleName 435 | } 436 | } -------------------------------------------------------------------------------- /core/src/main/java/com/mcxinyu/scheduletimeruler/TimeRulerView.kt: -------------------------------------------------------------------------------- 1 | package com.mcxinyu.scheduletimeruler 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.graphics.* 6 | import android.util.AttributeSet 7 | import android.view.GestureDetector 8 | import android.view.MotionEvent 9 | import android.view.View 10 | import android.widget.Scroller 11 | import androidx.annotation.ColorInt 12 | import androidx.core.content.res.ResourcesCompat 13 | import androidx.core.view.GestureDetectorCompat 14 | import com.mcxinyu.scheduletimeruler.model.TimeModel 15 | import java.util.* 16 | import kotlin.math.ceil 17 | import kotlin.math.min 18 | import kotlin.properties.Delegates 19 | 20 | 21 | /** 22 | * @author [yuefeng](mailto:mcxinyu@foxmail.com) in 2021/12/24. 23 | */ 24 | open class TimeRulerView @JvmOverloads constructor( 25 | context: Context, attrs: AttributeSet? = null 26 | ) : View(context, attrs), GestureDetector.OnGestureListener { 27 | 28 | /** 29 | * 0 横向 30 | * 1 竖向 31 | */ 32 | var orientation: Int 33 | var typeface: Typeface? = null 34 | 35 | @ColorInt 36 | var tickTextColor: Int 37 | var tickTextSize: Float 38 | var showTickText: Boolean 39 | 40 | var showTick: Boolean 41 | var maxTickSpace: Float 42 | 43 | @ColorInt 44 | var keyTickColor: Int 45 | var keyTickWidth: Float 46 | var keyTickHeight: Float 47 | 48 | @ColorInt 49 | var cursorLineColor: Int 50 | var cursorLineWidth: Float 51 | var cursorLinePositionPercentage: Float 52 | var cursorLinePosition by Delegates.notNull() 53 | var showCursorText: Boolean 54 | var showCursorLine: Boolean 55 | 56 | @ColorInt 57 | var baselineOutDayColor: Int 58 | 59 | @ColorInt 60 | var baselineColor: Int 61 | var baselineWidth: Float 62 | var baselinePositionPercentage: Float 63 | var baselinePosition by Delegates.notNull() 64 | var showBaseline: Boolean 65 | 66 | private val paint = Paint() 67 | 68 | protected var timeModel = TimeModel() 69 | private set 70 | 71 | /** 72 | * 游标所在位置时间 73 | */ 74 | var cursorTimeValue = System.currentTimeMillis() 75 | set(value) { 76 | field = when { 77 | value < timeModel.startTimeValue -> timeModel.startTimeValue 78 | value > timeModel.endTimeValue -> timeModel.endTimeValue 79 | else -> value 80 | } 81 | onCursorListener?.onProgressChanged(cursorTimeValue) 82 | invalidate() 83 | } 84 | 85 | /** 86 | * 每毫秒时间占用像素 87 | */ 88 | protected var millisecondUnitPixel by Delegates.notNull() 89 | 90 | /** 91 | * 每格占用像素 92 | */ 93 | protected var tickSpacePixel by Delegates.notNull() 94 | 95 | protected var scrollHappened: Boolean = false 96 | protected var gestureDetectorCompat = GestureDetectorCompat(context, this) 97 | protected var status: Int = OnScrollListener.STATUS_IDLE 98 | set(value) { 99 | field = value 100 | onScrollListener?.onScrollStateChanged(status) 101 | } 102 | 103 | protected var scroller = Scroller(context) 104 | 105 | init { 106 | val typedArray = context.obtainStyledAttributes(attrs, R.styleable.TimeRulerView) 107 | 108 | val font = typedArray.getResourceId(R.styleable.TimeRulerView_trv_font, 0) 109 | if (font != 0) { 110 | typeface = ResourcesCompat.getFont(context, font) 111 | } 112 | 113 | orientation = typedArray.getInt(R.styleable.TimeRulerView_trv_orientation, 1) 114 | 115 | showBaseline = typedArray.getBoolean(R.styleable.TimeRulerView_trv_showBaseline, true) 116 | baselinePositionPercentage = 117 | typedArray.getFloat(R.styleable.TimeRulerView_trv_baselinePosition, 0.25f) 118 | baselineColor = 119 | typedArray.getColor(R.styleable.TimeRulerView_trv_baselineColor, Color.LTGRAY) 120 | baselineOutDayColor = typedArray.getColor( 121 | R.styleable.TimeRulerView_trv_baselineOutDayColor, 122 | Color.parseColor("#FFFAFAFA") 123 | ) 124 | baselineWidth = typedArray.getDimension( 125 | R.styleable.TimeRulerView_trv_baselineWidth, 126 | 1.toPx(context) 127 | ) 128 | 129 | showCursorLine = typedArray.getBoolean(R.styleable.TimeRulerView_trv_showCursorLine, true) 130 | showCursorText = typedArray.getBoolean(R.styleable.TimeRulerView_trv_showCursorText, true) 131 | cursorLinePositionPercentage = typedArray.getFloat( 132 | R.styleable.TimeRulerView_trv_cursorLinePosition, 133 | if (orientation == 0) 0.5f else 0.3f 134 | ) 135 | cursorLineColor = typedArray.getColor( 136 | R.styleable.TimeRulerView_trv_cursorLineColor, 137 | Color.BLUE 138 | ) 139 | cursorLineWidth = typedArray.getDimension( 140 | R.styleable.TimeRulerView_trv_cursorLineWidth, 141 | 1.toPx(context) 142 | ) 143 | 144 | keyTickHeight = typedArray.getDimension( 145 | R.styleable.TimeRulerView_trv_keyTickHeight, 146 | 8.toPx(context) 147 | ) 148 | keyTickWidth = typedArray.getDimension( 149 | R.styleable.TimeRulerView_trv_keyTickWidth, 150 | 1.toPx(context) 151 | ) 152 | keyTickColor = typedArray.getColor( 153 | R.styleable.TimeRulerView_trv_keyTickColor, 154 | Color.GRAY 155 | ) 156 | 157 | showTick = typedArray.getBoolean(R.styleable.TimeRulerView_trv_showTick, true) 158 | maxTickSpace = typedArray.getDimension( 159 | R.styleable.TimeRulerView_trv_minTickSpace, 160 | 80.toPx(context) 161 | ) 162 | 163 | showTickText = typedArray.getBoolean(R.styleable.TimeRulerView_trv_showTickText, true) 164 | tickTextSize = typedArray.getDimension( 165 | R.styleable.TimeRulerView_trv_tickTextSize, 166 | 14.toPxForSp(context) 167 | ) 168 | tickTextColor = typedArray.getColor( 169 | R.styleable.TimeRulerView_trv_tickTextColor, 170 | Color.DKGRAY 171 | ) 172 | 173 | typedArray.recycle() 174 | 175 | initThing() 176 | } 177 | 178 | private fun initThing() { 179 | paint.isAntiAlias = true 180 | paint.isDither = true 181 | paint.style = Paint.Style.FILL_AND_STROKE 182 | typeface?.let { paint.typeface = typeface } 183 | } 184 | 185 | /** 186 | * 设置时间范围,接受同一天的时间 187 | * 188 | * @param start Long 189 | * @param end Long 190 | */ 191 | fun setRange(start: Long, end: Long) { 192 | if (start >= end) { 193 | throw IllegalArgumentException("the start time must be greater than the end time") 194 | } 195 | 196 | val sCalendar = Calendar.getInstance().apply { 197 | time = Date(start) 198 | } 199 | val eCalendar = Calendar.getInstance().apply { 200 | time = Date(end) 201 | } 202 | 203 | // if (eCalendar.timeInMillis - sCalendar.timeInMillis > 1000 * 60 * 60 * 24) { 204 | // throw IllegalArgumentException("The difference between the start time and the end time cannot be more than 24 hours") 205 | // } 206 | 207 | timeModel.startTimeValue = start 208 | timeModel.endTimeValue = end 209 | 210 | cursorTimeValue = cursorTimeValue 211 | } 212 | 213 | override fun onSizeChanged(width: Int, height: Int, oldw: Int, oldh: Int) { 214 | super.onSizeChanged(width, height, oldw, oldh) 215 | 216 | if (orientation == 0) { 217 | cursorLinePosition = width * cursorLinePositionPercentage 218 | baselinePosition = height * (1 - baselinePositionPercentage) 219 | } else { 220 | cursorLinePosition = height * cursorLinePositionPercentage 221 | baselinePosition = width * baselinePositionPercentage 222 | } 223 | 224 | millisecondUnitPixel = maxTickSpace / timeModel.unitTimeValue 225 | tickSpacePixel = timeModel.unitTimeValue * millisecondUnitPixel 226 | } 227 | 228 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 229 | super.onMeasure(widthMeasureSpec, heightMeasureSpec) 230 | // setMeasuredDimension( 231 | // getDefaultSize(suggestedMinimumWidth, widthMeasureSpec), getHeightSize( 232 | // suggestedMinimumHeight, heightMeasureSpec 233 | // ) 234 | // ) 235 | } 236 | 237 | private fun getHeightSize(size: Int, heightMeasureSpec: Int): Int { 238 | var result = size 239 | val contentHeight: Int = calculateContentWidth() 240 | val specMode = MeasureSpec.getMode(heightMeasureSpec) 241 | val specSize = MeasureSpec.getSize(heightMeasureSpec) 242 | when (specMode) { 243 | MeasureSpec.UNSPECIFIED -> result = if (size > contentHeight) size else contentHeight 244 | MeasureSpec.AT_MOST -> result = contentHeight 245 | MeasureSpec.EXACTLY -> result = 246 | if (specSize > contentHeight) specSize else contentHeight 247 | } 248 | return result 249 | } 250 | 251 | protected fun calculateContentWidth(): Int { 252 | var tickValueHeight = 0 253 | if (showTickText) { 254 | //让字体不要溢出,这里应该动态换算基线左边还有位置显示文本么? 255 | paint.textSize = 20.toPx(context) 256 | val fontMetrics: Paint.FontMetrics = paint.getFontMetrics() 257 | val ceil = ceil((fontMetrics.bottom - fontMetrics.top).toDouble()) 258 | tickValueHeight = ceil.toInt() 259 | } 260 | return ((keyTickHeight + tickValueHeight) / baselinePositionPercentage + 0.5f).toInt() 261 | } 262 | 263 | override fun onDraw(canvas: Canvas) { 264 | super.onDraw(canvas) 265 | 266 | onDrawBaseline(canvas) 267 | 268 | onDrawTick(canvas) 269 | 270 | onDrawCursor(canvas) 271 | } 272 | 273 | private val rect = Rect() 274 | 275 | /** 276 | * 画刻度 277 | * 278 | * @param canvas Canvas 279 | */ 280 | protected open fun onDrawTick(canvas: Canvas) { 281 | //在游标线以上包含的今日时间 282 | val frontTodayTimeRange = cursorTimeValue - timeModel.startTimeValue 283 | //可见的第一个刻度的时间在屏幕外的偏移量 284 | val frontFirstTimeOffset = frontTodayTimeRange % timeModel.unitTimeValue 285 | //向前走,距离游标线最近的刻度代表的时间 286 | val frontLastTimeOffsetValue = cursorTimeValue - frontFirstTimeOffset 287 | //向前走,距离游标线最近的刻度所在位置 288 | val frontLastTimePosition = cursorLinePosition - frontFirstTimeOffset * millisecondUnitPixel 289 | val frontCount = cursorLinePosition / tickSpacePixel 290 | 291 | //从游标线往前画 292 | for (i in 0..ceil(frontCount.toDouble()).toInt()) { 293 | val timeValue = frontLastTimeOffsetValue - timeModel.unitTimeValue * i 294 | if (timeValue < timeModel.startTimeValue) { 295 | break 296 | } 297 | 298 | var x = baselinePosition - keyTickHeight 299 | var y = frontLastTimePosition - tickSpacePixel * i 300 | 301 | if (orientation == 0) { 302 | x = frontLastTimePosition - tickSpacePixel * i 303 | y = baselinePosition 304 | } 305 | 306 | if (orientation == 0) { 307 | onDrawTickBaseline( 308 | canvas, 309 | x, 310 | baselinePosition, 311 | min(x + tickSpacePixel, cursorLinePosition), 312 | baselinePosition - baselineWidth, 313 | timeValue 314 | ) 315 | } else { 316 | onDrawTickBaseline( 317 | canvas, 318 | baselinePosition, 319 | y, 320 | baselinePosition + baselineWidth, 321 | min(y + tickSpacePixel, cursorLinePosition), 322 | timeValue 323 | ) 324 | } 325 | 326 | onDrawTickLine(canvas, x, y, timeValue) 327 | onDrawTickText(canvas, x, y, timeValue) 328 | } 329 | 330 | val backFirstTimeValue = frontLastTimeOffsetValue + timeModel.unitTimeValue 331 | val backFirstTimePosition = frontLastTimePosition + tickSpacePixel 332 | val backCount = 333 | ((if (orientation == 0) width else height) - cursorLinePosition) / tickSpacePixel 334 | 335 | //从游标线往后画 336 | for (i in 0..ceil(backCount.toDouble()).toInt()) { 337 | val timeValue = backFirstTimeValue + timeModel.unitTimeValue * i 338 | 339 | var x = baselinePosition - keyTickHeight 340 | var y = backFirstTimePosition + tickSpacePixel * i 341 | 342 | if (orientation == 0) { 343 | x = backFirstTimePosition + tickSpacePixel * i 344 | y = baselinePosition 345 | } 346 | 347 | if (orientation == 0) { 348 | onDrawTickBaseline( 349 | canvas, 350 | min(x, cursorLinePosition), 351 | baselinePosition, 352 | x + min( 353 | tickSpacePixel, 354 | (timeModel.endTimeValue - timeValue) * millisecondUnitPixel 355 | ), 356 | baselinePosition - baselineWidth, 357 | timeValue 358 | ) 359 | } else { 360 | onDrawTickBaseline( 361 | canvas, 362 | baselinePosition, 363 | min(y, cursorLinePosition), 364 | baselinePosition + baselineWidth, 365 | y + min( 366 | tickSpacePixel, 367 | (timeModel.endTimeValue - timeValue) * millisecondUnitPixel 368 | ), 369 | timeValue 370 | ) 371 | } 372 | if (timeValue <= timeModel.endTimeValue) { 373 | onDrawTickLine(canvas, x, y, timeValue) 374 | onDrawTickText(canvas, x, y, timeValue) 375 | } 376 | 377 | if (timeValue >= timeModel.endTimeValue) { 378 | break 379 | } 380 | } 381 | } 382 | 383 | /** 384 | * 画刻度值 385 | * 386 | * @param canvas Canvas 387 | * @param x Float 388 | * @param y Float 389 | * @param timeValue Long 390 | */ 391 | protected open fun onDrawTickText(canvas: Canvas, x: Float, y: Float, timeValue: Long) { 392 | if (showTickText) { 393 | paint.color = tickTextColor 394 | paint.textAlign = Paint.Align.LEFT 395 | paint.textSize = tickTextSize 396 | 397 | val text = onGetSimpleDateFormat().format(timeValue) 398 | 399 | paint.getTextBounds(text, 0, text.length, rect) 400 | val w = rect.width() 401 | val h = rect.height() 402 | 403 | if (orientation == 0) { 404 | canvas.drawText(text, x - w / 2, y + h * 1.5f + keyTickHeight, paint) 405 | } else { 406 | canvas.drawText(text, x - w - h, y + h / 2, paint) 407 | } 408 | } 409 | } 410 | 411 | /** 412 | * 画刻度内的标线 413 | * 414 | * @param canvas Canvas 415 | * @param x1 Float 416 | * @param y1 Float 417 | * @param x2 Float 418 | * @param y2 Float 419 | * @param timeValue Long 420 | */ 421 | protected open fun onDrawTickBaseline( 422 | canvas: Canvas, 423 | x1: Float, y1: Float, 424 | x2: Float, y2: Float, 425 | timeValue: Long 426 | ) { 427 | if (showBaseline) { 428 | paint.color = baselineColor 429 | 430 | rect.set(x1.toInt(), y1.toInt(), x2.toInt(), y2.toInt()) 431 | canvas.drawRect(rect, paint) 432 | } 433 | } 434 | 435 | /** 436 | * 画刻度线 437 | * 438 | * @param canvas Canvas 439 | * @param x Float 440 | * @param y Float 441 | * @param timeValue Long 442 | */ 443 | protected open fun onDrawTickLine(canvas: Canvas, x: Float, y: Float, timeValue: Long) { 444 | if (showTick) { 445 | paint.color = keyTickColor 446 | paint.strokeWidth = keyTickWidth 447 | if (orientation == 0) { 448 | canvas.drawLine(x, y, x, y + keyTickHeight, paint) 449 | } else { 450 | canvas.drawLine(x, y, baselinePosition, y, paint) 451 | } 452 | paint.strokeWidth = 1f 453 | } 454 | } 455 | 456 | private val px8 = 8.toPx(context) 457 | 458 | /** 459 | * 画游标 460 | * 461 | * @param canvas Canvas 462 | */ 463 | protected open fun onDrawCursor(canvas: Canvas) { 464 | if (showCursorLine) { 465 | paint.color = cursorLineColor 466 | paint.strokeWidth = cursorLineWidth 467 | 468 | if (orientation == 0) { 469 | canvas.drawLine(cursorLinePosition, 0f, cursorLinePosition, height.toFloat(), paint) 470 | } else { 471 | canvas.drawLine(0f, cursorLinePosition, width.toFloat(), cursorLinePosition, paint) 472 | } 473 | 474 | paint.strokeWidth = 1f 475 | } 476 | if (showCursorText) { 477 | val text = simpleDateFormat2.format(cursorTimeValue) 478 | 479 | paint.getTextBounds(text, 0, text.length, rect) 480 | 481 | paint.textAlign = Paint.Align.CENTER 482 | 483 | if (orientation == 0) { 484 | canvas.drawRoundRect( 485 | cursorLinePosition - rect.width() / 2 - px8 * 1.5f, 486 | baselinePosition - rect.height() * 2 - px8 * 2f - baselineWidth, 487 | cursorLinePosition + rect.width() / 2 + px8 * 1.5f, 488 | baselinePosition - px8 - baselineWidth, 489 | rect.height() * 2f, 490 | rect.height() * 2f, 491 | paint 492 | ) 493 | 494 | paint.color = Color.WHITE 495 | canvas.drawText( 496 | text, 497 | cursorLinePosition, 498 | baselinePosition - rect.height() / 2 - px8 * 1.5f - baselineWidth, 499 | paint 500 | ) 501 | } else { 502 | canvas.drawText(text, width / 2f, cursorLinePosition - rect.height(), paint) 503 | } 504 | } 505 | } 506 | 507 | /** 508 | * 画表现的底线 509 | * 510 | * @param canvas Canvas 511 | */ 512 | protected open fun onDrawBaseline(canvas: Canvas) { 513 | if (showBaseline) { 514 | paint.color = baselineOutDayColor 515 | 516 | if (orientation == 0) 517 | rect.set( 518 | 0, 519 | baselinePosition.toInt(), 520 | width, 521 | (baselinePosition - baselineWidth).toInt() 522 | ) 523 | else 524 | rect.set( 525 | baselinePosition.toInt(), 526 | 0, 527 | (baselinePosition + baselineWidth).toInt(), 528 | height 529 | ) 530 | canvas.drawRect(rect, paint) 531 | } 532 | } 533 | 534 | @SuppressLint("ClickableViewAccessibility") 535 | override fun onTouchEvent(event: MotionEvent): Boolean { 536 | gestureDetectorCompat.onTouchEvent(event) 537 | if (status != OnScrollListener.STATUS_SCROLL_FLING) { 538 | if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { 539 | status = OnScrollListener.STATUS_IDLE 540 | } 541 | } 542 | return true 543 | } 544 | 545 | override fun onDown(e: MotionEvent): Boolean { 546 | if (status == OnScrollListener.STATUS_SCROLL_FLING) { 547 | scroller.forceFinished(true) 548 | } else { 549 | scrollHappened = false 550 | } 551 | status = OnScrollListener.STATUS_DOWN 552 | return true 553 | } 554 | 555 | override fun onShowPress(e: MotionEvent) {} 556 | 557 | override fun onSingleTapUp(e: MotionEvent): Boolean = performClick() 558 | 559 | override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): 560 | Boolean { 561 | if (e2.pointerCount > 1) { 562 | return false 563 | } 564 | if (!scrollHappened) { 565 | scrollHappened = true 566 | return true 567 | } 568 | 569 | status = OnScrollListener.STATUS_SCROLL 570 | 571 | val increment = (if (orientation == 0) distanceX else distanceY) / millisecondUnitPixel 572 | cursorTimeValue += increment.toLong() 573 | 574 | var result = true 575 | if (cursorTimeValue > timeModel.endTimeValue) { 576 | cursorTimeValue = timeModel.endTimeValue 577 | result = false 578 | } else if (cursorTimeValue < timeModel.startTimeValue) { 579 | cursorTimeValue = timeModel.startTimeValue 580 | result = false 581 | } 582 | 583 | onScrollListener?.onScrolled(distanceX.toInt(), distanceY.toInt()) 584 | 585 | invalidate() 586 | 587 | return result 588 | } 589 | 590 | override fun onLongPress(e: MotionEvent) {} 591 | 592 | override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): 593 | Boolean { 594 | status = OnScrollListener.STATUS_SCROLL_FLING 595 | 596 | val start = ((cursorTimeValue - timeModel.startTimeValue) * millisecondUnitPixel).toInt() 597 | val max = 598 | ((timeModel.endTimeValue - timeModel.startTimeValue) * millisecondUnitPixel).toInt() 599 | 600 | scroller.fling( 601 | if (orientation == 0) start else 0, if (orientation == 0) 0 else start, 602 | -velocityX.toInt(), -velocityY.toInt(), 603 | 0, max, 604 | 0, max 605 | ) 606 | scroller.isFinished 607 | invalidate() 608 | 609 | return true 610 | } 611 | 612 | private var currX: Int? = null 613 | private var currY: Int? = null 614 | override fun computeScroll() { 615 | super.computeScroll() 616 | if (scroller.computeScrollOffset()) { 617 | val cX = scroller.currX 618 | val cY = scroller.currY 619 | cursorTimeValue = 620 | timeModel.startTimeValue + ((if (orientation == 0) cX else cY) / millisecondUnitPixel).toLong() 621 | if (cursorTimeValue > timeModel.endTimeValue) { 622 | cursorTimeValue = timeModel.endTimeValue 623 | } else if (cursorTimeValue < timeModel.startTimeValue) { 624 | cursorTimeValue = timeModel.startTimeValue 625 | } 626 | if (currY != null && currX != null) { 627 | onScrollListener?.onScrolled(scroller.currY - currY!!, scroller.currX - currX!!) 628 | } 629 | currX = scroller.currX 630 | currY = scroller.currY 631 | invalidate() 632 | } else { 633 | if (status == OnScrollListener.STATUS_SCROLL_FLING) { 634 | status = OnScrollListener.STATUS_IDLE 635 | } 636 | } 637 | } 638 | 639 | open fun onGetSimpleDateFormat() = simpleDateFormat 640 | 641 | companion object { 642 | val TAG = TimeRulerView::class.java.simpleName 643 | } 644 | 645 | var onCursorListener: OnCursorListener? = null 646 | 647 | interface OnCursorListener { 648 | fun onProgressChanged(cursorTimeValue: Long) 649 | } 650 | 651 | var onScrollListener: OnScrollListener? = null 652 | 653 | interface OnScrollListener { 654 | companion object { 655 | //0 656 | const val STATUS_IDLE = 0 657 | 658 | //1 659 | const val STATUS_DOWN = STATUS_IDLE + 1 660 | 661 | //2 662 | const val STATUS_SCROLL = STATUS_DOWN + 1 663 | 664 | //3 665 | const val STATUS_SCROLL_FLING = STATUS_SCROLL + 1 666 | 667 | //4 668 | const val STATUS_ZOOM = STATUS_SCROLL_FLING + 1 669 | } 670 | 671 | /** 672 | * 673 | * @param newState The updated scroll state. One of [STATUS_IDLE], 674 | * [STATUS_DOWN] or [STATUS_SCROLL] or [STATUS_SCROLL_FLING] or STATUS_ZOOM 675 | */ 676 | fun onScrollStateChanged(newState: Int) 677 | 678 | /** 679 | * 680 | * @param dx The amount of horizontal scroll. 681 | * @param dy The amount of vertical scroll. 682 | */ 683 | fun onScrolled(dx: Int, dy: Int) 684 | } 685 | } 686 | 687 | --------------------------------------------------------------------------------