├── 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/#mcxinyu/ScheduleTimeRuler)
4 | [](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 |
12 | 
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 |
--------------------------------------------------------------------------------