10 |
11 | * [Getting Started](#getting-started-)
12 | * [Example](#example-)
13 | * [Features](#features-)
14 | * [Attributes](#attributes)
15 | * [``area_margin``](#area_margin)
16 | * [``inner_color`` & ``outer_color``](#inner_color--outer_color)
17 | * [``border_radius``](#border_radius)
18 | * [``text``, ``text_size``, ``text_style``, ``text_appearance``](#text-text_size-text_style-text_appearance)
19 | * [``slider_height``](#slider_height)
20 | * [``slider_locked``](#slider_locked)
21 | * [``animation_duration``](#animation_duration)
22 | * [``slider_reversed``](#slider_reversed)
23 | * [``slider_icon``](#slider_icon)
24 | * [``complete_icon``](#complete_icon)
25 | * [``bump_vibration``](#bump_vibration)
26 | * [``rotate_icon``](#rotate_icon)
27 | * [``android:elevation``](#androidelevation)
28 | * [``state_complete``](#state_complete)
29 | * [``bounce_on_start``](#bounce_on_start)
30 | * [Event callbacks](#event-callbacks)
31 | * [Demo](#demo-)
32 | * [Building/Testing](#buildingtesting-)
33 | * [CircleCI](#circleci-)
34 | * [TravisCI](#travisci-)
35 | * [Building locally](#building-locally)
36 | * [Testing](#testing)
37 | * [Contributing](#contributing-)
38 | * [Honorable Mentions](#honorable-mentions-)
39 | * [License](#license-)
40 |
41 | ## Getting Started 👣
42 |
43 | **Slide To Act** is distributed through [Maven Central](https://search.maven.org/artifact/com.ncorti/slidetoact). To use it you need to add the following **Gradle dependency** to your **android app gradle file** (NOT the root file):
44 |
45 | ```groovy
46 | dependencies {
47 | implementation "com.ncorti:slidetoact:0.11.0"
48 | }
49 | ```
50 |
51 | Or you can download the .AAR artifact [directly from Maven Central](https://search.maven.org/artifact/com.ncorti/slidetoact/0.11.0/aar).
52 |
53 | ## Example 🚸
54 |
55 | After setting up the Gradle dependency, you can use ``SlideToActView`` widgets inside your **XML Layout files**
56 |
57 | ```xml
58 |
63 | ```
64 |
65 | And bind them inside your **Java/Kotlin** code:
66 | ```java
67 | SlideToActView sta = (SlideToActView) findViewById(R.id.example);
68 | ```
69 |
70 | ## Features 🎨
71 |
72 | * **100% Vectorial**, no .png or other assets provided.
73 | * **Fancy animations!** 🦄
74 | * **API >= 14** compatible (since v0.2.0)
75 | * Easy to integrate (just a gradle compile line).
76 | * Integrated with your **app theme** 🖼.
77 | * Works **out of the box**, no customization needed.
78 | * Written in **Kotlin** (but you don't need Kotlin to use it)!
79 | * **UX Friendly** 🐣, button will bump to complete if it's over the 80% of the slider (see the following gif).
80 |
81 |
82 |
83 |
84 |
85 | ### Attributes
86 |
87 | By the default, every ``SlideToActView`` widget fits to your app using the ``colorAccent`` and the ``colorBackground`` parameters from your theme. You can customize your ``SlideToActView`` using the following **custom attributes**.
88 |
89 | ```xml
90 |
105 | ```
106 |
107 | #### ``area_margin``
108 |
109 | Use the ``area_marging`` attribute to control the **margin of the inner circular button** from the outside area. If not set, this attribute defaults to **8dp**.
110 |
111 |
112 |
113 | You can also use a **negative** value to have the inner circular button bigger than the slider. To achieve this effect you also need to set `android:clipChildren="false"` on the parent layout, like:
114 |
115 | ```xml
116 |
120 |
121 |
125 | ```
126 |
127 | to obtain this behavior:
128 |
129 |
130 |
131 | #### ``icon_margin``
132 |
133 | The attribute ``icon_margin`` let you control the margin of the icon inside the circular button. This makes the icon bigger because can take up more space in the button.
134 |
135 | This is especially useful when you want to make the height of the slider smaller (see ``slider_height``). In this case, if you don't adjust the ``icon_margin`` the image can be too much tiny. By default, the ``icon_margin`` is set to 16dp.
136 |
137 | In next image you can see how it looks like:
138 |
139 |
140 |
141 | #### ``inner_color`` & ``outer_color``
142 |
143 | Use the ``outer_color`` attribute to control the **color of the external area** and the **color of the arrow icon**. If not set, this attribute defaults to **colorAccent** from your theme.
144 |
145 | Use the ``inner_color`` attribute to control the **color of the inner circular button**, the **color of the tick icon** and the **color of the text**. If not set, this attribute defaults to **colorBackground** from your theme.
146 |
147 |
148 |
149 | #### ``border_radius``
150 |
151 | Use the ``border_radius`` attribute to control the **radius** of the **inner circular button** and of the **external area**. A ``border_radius`` set to **0dp** will result in a square slider. If not set, this attribute will render your slider as a **circle** (default behavior).
152 |
153 |
154 |
155 | #### ``text``, ``text_size``, ``text_style``, ``text_appearance``
156 |
157 | Use the ``text`` attribute to control the **text of your slider**. If not set, this attribute defaults to **SlideToActView**.
158 |
159 | Use the ``text_size`` attribute to control the **size** of the **text of your slider**. A ``text_size`` set to **0sp** will result in hiding the text. If not set, this attribute defaults to **16sp**.
160 |
161 | Use the ``text_style`` attribute to control the **style** of your text. Accepted values are ``normal``, ``bold`` and ``italic``.
162 |
163 | Use the ``text_appearance`` attribute to provide an Android `TextAppearance` style to fully customize your Text.
164 | Please use this attribute if you want to use a **custom font** or set the text to be **all caps**.
165 |
166 |
167 |
168 | #### ``slider_height``
169 |
170 | Use the ``slider_height`` attribute to control the **desired height** of the widget. If not set, the widget will try to render with **72dp** of height.
171 |
172 |
173 |
174 | #### ``slider_locked``
175 |
176 | Use the ``slider_locked`` attribute to **lock the slider** (this is a boolean attribute). When a slider is locked, will always bump the button to the beginning (default is false).
177 |
178 |
179 |
180 |
181 |
182 | You can also toggle this attribute programmatically with the provided setter.
183 |
184 | ```java
185 | SlideToActView sta = (SlideToActView) findViewById(R.id.slider);
186 | sta.setLocked(true);
187 | ```
188 |
189 | #### ``animation_duration``
190 |
191 | Use the ``animation_duration`` attribute to **set the duration** of the complete and reset animation (in milliseconds).
192 |
193 | You can also set animation duration programmatically with the provided setter.
194 |
195 | ```kotlin
196 | val sta = (SlideToActView) findViewById(R.id.slider);
197 | sta.animDuration = 600
198 | ```
199 |
200 | #### ``slider_reversed``
201 |
202 | Use the ``slider_reversed`` attribute to **reverse the slider** (this is a boolean attribute). When a slider is reversed, the cursor will appear on the right and will progress to the left. (default is false).
203 |
204 |
205 |
206 |
207 |
208 | You can also toggle this attribute programmatically with the provided setter.
209 |
210 | ```java
211 | SlideToActView sta = (SlideToActView) findViewById(R.id.slider);
212 | sta.setReversed(true);
213 | ```
214 |
215 | #### ``slider_icon``
216 |
217 | You can set a custom icon by setting the ``slider_icon``attribute to a drawable resource.
218 |
219 |
220 |
221 |
222 |
223 | ```xml
224 | app:slider_icon="@drawable/custom_icon"
225 | ```
226 |
227 | You can also set a custom icon programmatically with the provided setter.
228 |
229 | ```java
230 | SlideToActView sta = findViewById(R.id.slider);
231 | sta.setSliderIcon(R.drawable.custom_icon);
232 | ```
233 |
234 | You can also disable the rotation by setting the ``rotate_icon`` attribute to false.
235 |
236 | #### ``complete_icon``
237 | You can set a custom complete icon by setting the ``complete_icon``attribute to a drawable resource.
238 |
239 |
240 |
241 |
242 |
243 | ```xml
244 | app:complete_icon="@drawable/slidetoact_ic_check"
245 | ```
246 |
247 | You can also set a custom complete icon programmatically with the provided setter.
248 |
249 | ```java
250 | SlideToActView sta = findViewById(R.id.slider);
251 | sta.setCompleteIcon(R.drawable.custom_complete_animated);
252 | ```
253 |
254 | #### ``slider_icon_color``
255 |
256 | You can set a custom color for the icon by setting the ``slider_icon_color`` attribute.
257 |
258 |
259 |
260 |
261 |
262 | This attribute defaults to the ``outer_color`` if set. If ``outer_color`` is not set, this attribute defaults to **colorAccent** from your theme.
263 |
264 | #### ``bump_vibration``
265 |
266 | You can make the device vibrate when the cursor "bumps" to the end of the sliding path by setting the period of vibration through bump_vibration attribute in your layout XML (default is 0)
267 |
268 | ```xml
269 | app:bump_vibration="50"
270 | ```
271 |
272 | Note that the period of vibration is in milliseconds
273 |
274 | You can achieve the same programmatically using the setter:
275 |
276 | ```java
277 | SlideToActView sta = (SlideToActView) findViewById(R.id.slider);
278 | sta.setBumpVibration(50);
279 | ```
280 |
281 | In order for this feature to work, you need have the permission ```android.permission.VIBRATE``` in your AndroidManifest.xml
282 |
283 | ```xml
284 |
285 | ```
286 |
287 | #### ``android:elevation``
288 |
289 | Use the ``android:elevation`` attribute to set the **elevation** of the widget. The widgets will take care of providing the proper ``ViewOutlineProvider`` during the whole animation (a.k.a. The shadow will be drawn properly).
290 |
291 |
292 |
293 | #### ``state_complete``
294 |
295 | Use ``state_complete`` attribute to create ``SlideToActView`` in complete state.
296 |
297 | ```xml
298 | app:state_complete="true"
299 | ```
300 |
301 | Can be also set programmatically.
302 |
303 | With full slide animation:
304 |
305 | ```java
306 | SlideToActView sta = (SlideToActView) findViewById(R.id.slider);
307 | sta.setCompleted(completed: true, withAnimation: true);
308 | ```
309 |
310 | Without slide animation:
311 |
312 | ```java
313 | SlideToActView sta = (SlideToActView) findViewById(R.id.slider);
314 | sta.setCompleted(completed: true, withAnimation: false);
315 | ```
316 |
317 |
318 |
319 |
320 |
321 | #### ``bounce_on_start``
322 |
323 | You can enable a bounce animation for the slider when the view is first rendered by setting the ``bounce_on_start`` attribute to true (default is false)
324 | Also you can set the duration of the bounce animation by setting the ``bounce_duration`` attribute (default is 2000)
325 | and repeat count by setting the ``bounce_repeat`` attribute (default is INFINITE)
326 |
327 |
328 |
329 |
330 |
331 |
332 | ### Event callbacks
333 |
334 | You can use the ``OnSlideCompleteListener`` and the ``OnSlideResetListener`` to simply interact with the widget. If you need to perform operations during animations, you can provide an ``OnSlideToActAnimationEventListener``. With the latter, you will be notified of every animation start/stop.
335 |
336 | You can try the **Event Callbacks** in the [Demo app](#demo) to better understand where every callback is called.
337 |
338 |
339 |
340 | ## Demo 📲
341 |
342 | Wonna see the widget in action? Just give a try to the **Example App**, it's inside the [**example**](example/) folder.
343 |
344 | Otherwise, you can just [download the **APK** from a CircleCI build](https://45-58338361-gh.circle-artifacts.com/0/tmp/circle-artifacts.uQdJ7rB/outputs/apk/example-debug.apk), and try it on a real device/emulator.
345 |
346 |
347 |
348 |
349 |
350 | ## Building/Testing ⚙️
351 |
352 | ### CircleCI [](https://circleci.com/gh/cortinico/slidetoact/tree/master)
353 |
354 | This projects is built with [**Circle CI**](https://circleci.com/gh/cortinico/slidetoact/). The CI environment takes care of building the library .AAR, the example app and to run the **Espresso** tests. **Artifacts** are exposed at the end of every build (both the .AAR and the .APK of the example app).
355 |
356 | ### TravisCI [](https://travis-ci.org/cortinico/slidetoact)
357 |
358 | [**TravisCI**](https://travis-ci.org/cortinico/slidetoact) builds are also running but they are considered **Legacy**. I'm probably going to dismiss it soon or later.
359 |
360 | ### Building locally
361 |
362 | Before building, make sure you have the following **updated components** from the Android SDK:
363 |
364 | * tools
365 | * platform-tools
366 | * build-tools-25.0.3
367 | * android-25
368 | * extra-android-support
369 | * extra-android-m2repository
370 | * extra-google-m2repository
371 |
372 | Then just clone the repo locally and build the .AAR with the following command:
373 |
374 | ```bash
375 | git clone git@github.com:cortinico/slidetoact.git
376 | cd slidetoact/
377 | ./gradlew slidetoact:assemble
378 | ```
379 |
380 | The assembled .AAR will be inside the **slidetoact/build/outputs/aar** folder.
381 |
382 | ### Testing
383 |
384 | Once you're able to build successfully, you can run Espresso tests locally with the following command.
385 |
386 | ```bash
387 | ./gradlew clean build connectedCheck
388 | ```
389 |
390 | Make sure your tests are all green ✅ locally before submitting PRs.
391 |
392 | ## Contributing 🤝
393 |
394 | **Looking for contributors! Don't be shy.** 😁 Feel free to open issues/pull requests to help me improve this project.
395 |
396 | * When reporting a new Issue, make sure to attach **Screenshots**, **Videos** or **GIFs** of the problem you are reporting.
397 | * When submitting a new PR, make sure tests are all green. Write new tests if necessary.
398 |
399 | ## Honorable mentions 🎖
400 |
401 | * [flutter-slide-to-act](https://github.com/imtoori/flutter-slide-to-act) - A porting of this widget for Flutter
402 |
403 | ## License 📄
404 |
405 | This project is licensed under the MIT License - see the [License](License) file for details
406 |
--------------------------------------------------------------------------------
/slidetoact/src/main/java/com/ncorti/slidetoact/SlideToActView.kt:
--------------------------------------------------------------------------------
1 | package com.ncorti.slidetoact
2 |
3 | import android.Manifest
4 | import android.animation.Animator
5 | import android.animation.AnimatorSet
6 | import android.animation.ValueAnimator
7 | import android.annotation.SuppressLint
8 | import android.content.Context
9 | import android.content.pm.PackageManager
10 | import android.content.res.TypedArray
11 | import android.graphics.Canvas
12 | import android.graphics.Outline
13 | import android.graphics.Paint
14 | import android.graphics.RectF
15 | import android.graphics.Typeface
16 | import android.graphics.drawable.Drawable
17 | import android.os.Build
18 | import android.os.VibrationEffect
19 | import android.os.Vibrator
20 | import android.os.VibratorManager
21 | import android.util.AttributeSet
22 | import android.util.Log
23 | import android.util.TypedValue
24 | import android.view.MotionEvent
25 | import android.view.View
26 | import android.view.ViewOutlineProvider
27 | import android.view.accessibility.AccessibilityNodeInfo
28 | import android.view.animation.AnticipateOvershootInterpolator
29 | import android.view.animation.OvershootInterpolator
30 | import android.widget.TextView
31 | import androidx.annotation.ColorInt
32 | import androidx.annotation.DrawableRes
33 | import androidx.annotation.RequiresApi
34 | import androidx.annotation.StyleRes
35 | import androidx.core.content.ContextCompat
36 | import androidx.core.content.res.ResourcesCompat
37 | import androidx.core.graphics.drawable.DrawableCompat
38 | import androidx.core.view.ViewCompat
39 | import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
40 | import androidx.core.widget.TextViewCompat
41 | import com.ncorti.slidetoact.SlideToActIconUtil.createIconAnimator
42 | import com.ncorti.slidetoact.SlideToActIconUtil.loadIconCompat
43 | import com.ncorti.slidetoact.SlideToActIconUtil.startIconAnimation
44 | import com.ncorti.slidetoact.SlideToActIconUtil.stopIconAnimation
45 | import com.ncorti.slidetoact.SlideToActIconUtil.tintIconCompat
46 |
47 | /**
48 | * Class representing the custom view, SlideToActView.
49 | *
50 | * SlideToActView is an elegant material designed slider, that enrich your app
51 | * with a "Slide-to-unlock" like widget.
52 | */
53 | class SlideToActView
54 | @JvmOverloads
55 | constructor(
56 | context: Context,
57 | xmlAttrs: AttributeSet? = null,
58 | defStyleAttr: Int = R.attr.slideToActViewStyle,
59 | ) : View(context, xmlAttrs, defStyleAttr) {
60 | companion object {
61 | const val TAG = "SlideToActView"
62 | }
63 |
64 | // -------------------- LAYOUT BOUNDS --------------------
65 |
66 | private var mDesiredSliderHeightDp: Float = 72F
67 | private var mDesiredSliderWidthDp: Float = 280F
68 | private var mDesiredSliderHeight: Int = 0
69 | private var mDesiredSliderWidth: Int = 0
70 |
71 | // -------------------- MEMBERS --------------------
72 |
73 | /** Height of the drawing area */
74 | private var mAreaHeight: Int = 0
75 |
76 | /** Width of the drawing area */
77 | private var mAreaWidth: Int = 0
78 |
79 | /** Actual Width of the drawing area, used for animations */
80 | private var mActualAreaWidth: Int = 0
81 |
82 | /** Border Radius, default to mAreaHeight/2, -1 when not initialized */
83 | private var mBorderRadius: Int = -1
84 |
85 | /** Margin of the cursor from the outer area */
86 | private var mActualAreaMargin: Int
87 | private val mOriginAreaMargin: Int
88 |
89 | /** Text message */
90 | var text: CharSequence = ""
91 | set(value) {
92 | field = value
93 | mTextView.text = value
94 | mTextPaint.set(mTextView.paint)
95 | invalidate()
96 | }
97 |
98 | /** Typeface for the text field */
99 | var typeFace = Typeface.NORMAL
100 | set(value) {
101 | field = value
102 | mTextView.typeface = Typeface.create("sans-serif-light", value)
103 | mTextPaint.set(mTextView.paint)
104 | invalidate()
105 | }
106 |
107 | /** Text Appearance used to fully customize the font */
108 | @StyleRes
109 | var textAppearance: Int = 0
110 | set(value) {
111 | field = value
112 | if (value != 0) {
113 | TextViewCompat.setTextAppearance(mTextView, value)
114 | mTextPaint.set(mTextView.paint)
115 | mTextPaint.color = mTextView.currentTextColor
116 | }
117 | }
118 |
119 | /** Outer color used by the slider (primary)*/
120 | @ColorInt
121 | var outerColor: Int = 0
122 | set(value) {
123 | field = value
124 | mOuterPaint.color = value
125 | invalidate()
126 | }
127 |
128 | /** Inner color used by the slider (secondary, icon and border) */
129 | @ColorInt
130 | var innerColor: Int = 0
131 | set(value) {
132 | field = value
133 | mInnerPaint.color = value
134 | invalidate()
135 | }
136 |
137 | /** Duration of the complete and reset animation (in milliseconds). */
138 | var animDuration: Long = 300
139 |
140 | /** Duration of vibration after bumping to the end point */
141 | var bumpVibration: Long = 0L
142 |
143 | @ColorInt
144 | var textColor: Int = 0
145 | set(value) {
146 | field = value
147 | mTextView.setTextColor(value)
148 | mTextPaint.color = textColor
149 | invalidate()
150 | }
151 |
152 | /** Custom Icon color */
153 | @ColorInt
154 | var iconColor: Int = 0
155 | set(value) {
156 | field = value
157 | DrawableCompat.setTint(mDrawableArrow, value)
158 | invalidate()
159 | }
160 |
161 | /** Custom Slider Icon */
162 | @DrawableRes
163 | var sliderIcon: Int = R.drawable.slidetoact_ic_arrow
164 | set(value) {
165 | field = value
166 | if (field != 0) {
167 | ResourcesCompat.getDrawable(context.resources, value, context.theme)?.let {
168 | mDrawableArrow = it
169 | DrawableCompat.setTint(it, iconColor)
170 | }
171 | invalidate()
172 | }
173 | }
174 |
175 | /** Slider cursor position (between 0 and (`mAreaWidth - mAreaHeight)) */
176 | private var mPosition: Int = 0
177 | set(value) {
178 | field = value
179 | if (mAreaWidth - mAreaHeight == 0) {
180 | // Avoid 0 division
181 | mPositionPerc = 0f
182 | mPositionPercInv = 1f
183 | return
184 | }
185 | mPositionPerc = value.toFloat() / (mAreaWidth - mAreaHeight).toFloat()
186 | mPositionPercInv = 1 - value.toFloat() / (mAreaWidth - mAreaHeight).toFloat()
187 | mEffectivePosition = mPosition
188 | }
189 |
190 | /** Slider cursor effective position. This is used to handle the `reversed` scenario. */
191 | private var mEffectivePosition: Int = 0
192 | set(value) {
193 | field = if (isReversed) (mAreaWidth - mAreaHeight) - value else value
194 | }
195 |
196 | /** Positioning of text */
197 | private var mTextYPosition = -1f
198 | private var mTextXPosition = -1f
199 |
200 | /** Private size for the text message */
201 | private var mTextSize: Int = 0
202 | set(value) {
203 | field = value
204 | mTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize.toFloat())
205 | mTextPaint.set(mTextView.paint)
206 | }
207 |
208 | /** Slider cursor position in percentage (between 0f and 1f) */
209 | private var mPositionPerc: Float = 0f
210 |
211 | /** 1/mPositionPerc */
212 | private var mPositionPercInv: Float = 1f
213 |
214 | // -------------------- ICONS --------------------
215 |
216 | private val mIconMargin: Int
217 |
218 | /** Margin for Arrow Icon */
219 | private var mArrowMargin: Int
220 |
221 | /** Current angle for Arrow Icon */
222 | private var mArrowAngle: Float = 0f
223 |
224 | /** Margin for Tick Icon */
225 | private var mTickMargin: Int
226 |
227 | /** Arrow drawable */
228 | private lateinit var mDrawableArrow: Drawable
229 |
230 | /** Tick drawable, if is an AnimatedVectorDrawable it will be animated */
231 | private var mDrawableTick: Drawable
232 | private var mFlagDrawTick: Boolean = false
233 |
234 | @DrawableRes
235 | var completeIcon: Int = 0
236 | set(value) {
237 | field = value
238 | if (field != 0) {
239 | mDrawableTick = loadIconCompat(context, value)
240 | invalidate()
241 | }
242 | }
243 |
244 | // -------------------- PAINT & DRAW --------------------
245 |
246 | /** Paint used for outer elements */
247 | private val mOuterPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
248 |
249 | /** Paint used for inner elements */
250 | private val mInnerPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
251 |
252 | /** Paint used for text elements */
253 | private var mTextPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
254 |
255 | /** TextView used for text elements */
256 | private var mTextView: TextView
257 |
258 | /** Inner rectangle (used for arrow rotation) */
259 | private var mInnerRect: RectF
260 |
261 | /** Outer rectangle (used for area drawing) */
262 | private var mOuterRect: RectF
263 |
264 | /** Grace value, when mPositionPerc > mGraceValue slider will perform the 'complete' operations */
265 | private val mGraceValue: Float = 0.8F
266 |
267 | /** Last X coordinate for the touch event */
268 | private var mLastX: Float = 0F
269 |
270 | /** Flag to understand if user is moving the slider cursor */
271 | private var mFlagMoving: Boolean = false
272 |
273 | /** Private flag to check if the slide gesture have been completed */
274 | private var mIsCompleted = false
275 |
276 | /** Private flag to check if the touch events should be handled or not */
277 | private var mIsRespondingToTouchEvents = true
278 |
279 | /** Public flag to lock the slider */
280 | var isLocked = false
281 |
282 | /** Public flag to reverse the slider by 180 degree */
283 | var isReversed = false
284 | set(value) {
285 | field = value
286 | // We reassign the position field to trigger the re-computation of the effective position.
287 | mPosition = mPosition
288 | invalidate()
289 | }
290 |
291 | /** Public flag to lock the rotation icon */
292 | var isRotateIcon = true
293 |
294 | /** Public flag to enable complete animation */
295 | var isAnimateCompletion = true
296 |
297 | /** Public Slide event listeners */
298 | var onSlideToActAnimationEventListener: OnSlideToActAnimationEventListener? = null
299 | var onSlideCompleteListener: OnSlideCompleteListener? = null
300 | var onSlideResetListener: OnSlideResetListener? = null
301 | var onSlideUserFailedListener: OnSlideUserFailedListener? = null
302 |
303 | private var bounceAnimator: ValueAnimator =
304 | ValueAnimator.ofInt(
305 | 0, 50, 0, 20, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
306 | )
307 |
308 | /** Public flag to enable bounce animation */
309 | private var mStartBounceAnimation: Boolean = false
310 |
311 | /** Public flag to set bounce animation duration */
312 | private var mBounceAnimationDuration: Long = 0
313 |
314 | /** Public flag to set bounce animation repeat time, default value infinity */
315 | private var mBounceAnimationRepeat: Int = 0
316 |
317 | init {
318 | val actualOuterColor: Int
319 | val actualInnerColor: Int
320 | val actualTextColor: Int
321 | val actualIconColor: Int
322 |
323 | val actualCompleteDrawable: Int
324 |
325 | mTextView = TextView(context)
326 | mTextPaint = mTextView.paint
327 |
328 | val attrs: TypedArray =
329 | context.theme.obtainStyledAttributes(
330 | xmlAttrs,
331 | R.styleable.SlideToActView,
332 | defStyleAttr,
333 | R.style.SlideToActView,
334 | )
335 | try {
336 | mDesiredSliderHeight =
337 | TypedValue.applyDimension(
338 | TypedValue.COMPLEX_UNIT_DIP,
339 | mDesiredSliderHeightDp,
340 | resources.displayMetrics,
341 | ).toInt()
342 | mDesiredSliderWidth =
343 | TypedValue.applyDimension(
344 | TypedValue.COMPLEX_UNIT_DIP,
345 | mDesiredSliderWidthDp,
346 | resources.displayMetrics,
347 | ).toInt()
348 |
349 | val defaultOuter =
350 | ContextCompat.getColor(
351 | this.context,
352 | R.color.slidetoact_defaultAccent,
353 | )
354 | val defaultWhite =
355 | ContextCompat.getColor(
356 | this.context,
357 | R.color.slidetoact_white,
358 | )
359 |
360 | with(attrs) {
361 | mDesiredSliderHeight =
362 | getDimensionPixelSize(
363 | R.styleable.SlideToActView_slider_height,
364 | mDesiredSliderHeight,
365 | )
366 | mBorderRadius = getDimensionPixelSize(R.styleable.SlideToActView_border_radius, -1)
367 |
368 | actualOuterColor = getColor(R.styleable.SlideToActView_outer_color, defaultOuter)
369 | actualInnerColor = getColor(R.styleable.SlideToActView_inner_color, defaultWhite)
370 |
371 | // For text color, check if the `text_color` is set.
372 | // if not check if the `outer_color` is set.
373 | // if not, default to white.
374 | actualTextColor =
375 | when {
376 | hasValue(R.styleable.SlideToActView_text_color) ->
377 | getColor(R.styleable.SlideToActView_text_color, defaultWhite)
378 | hasValue(R.styleable.SlideToActView_inner_color) -> actualInnerColor
379 | else -> defaultWhite
380 | }
381 |
382 | text = getString(R.styleable.SlideToActView_text) ?: ""
383 | typeFace = getInt(R.styleable.SlideToActView_text_style, 0)
384 | mTextSize =
385 | getDimensionPixelSize(
386 | R.styleable.SlideToActView_text_size,
387 | resources.getDimensionPixelSize(R.dimen.slidetoact_default_text_size),
388 | )
389 | textColor = actualTextColor
390 |
391 | // TextAppearance is the last as will have precedence over everything text related.
392 | textAppearance = getResourceId(R.styleable.SlideToActView_text_appearance, 0)
393 |
394 | isLocked = getBoolean(R.styleable.SlideToActView_slider_locked, false)
395 | isReversed = getBoolean(R.styleable.SlideToActView_slider_reversed, false)
396 | isRotateIcon = getBoolean(R.styleable.SlideToActView_rotate_icon, true)
397 | isAnimateCompletion =
398 | getBoolean(
399 | R.styleable.SlideToActView_animate_completion,
400 | true,
401 | )
402 | animDuration =
403 | getInteger(
404 | R.styleable.SlideToActView_animation_duration,
405 | 300,
406 | ).toLong()
407 | bumpVibration =
408 | getInt(
409 | R.styleable.SlideToActView_bump_vibration,
410 | 0,
411 | ).toLong()
412 |
413 | mOriginAreaMargin =
414 | getDimensionPixelSize(
415 | R.styleable.SlideToActView_area_margin,
416 | resources.getDimensionPixelSize(R.dimen.slidetoact_default_area_margin),
417 | )
418 | mActualAreaMargin = mOriginAreaMargin
419 |
420 | sliderIcon =
421 | getResourceId(
422 | R.styleable.SlideToActView_slider_icon,
423 | R.drawable.slidetoact_ic_arrow,
424 | )
425 |
426 | // For icon color. check if the `slide_icon_color` is set.
427 | // if not check if the `outer_color` is set.
428 | // if not, default to defaultOuter.
429 | actualIconColor =
430 | when {
431 | hasValue(R.styleable.SlideToActView_slider_icon_color) ->
432 | getColor(R.styleable.SlideToActView_slider_icon_color, defaultOuter)
433 | hasValue(R.styleable.SlideToActView_outer_color) -> actualOuterColor
434 | else -> defaultOuter
435 | }
436 | actualCompleteDrawable =
437 | getResourceId(
438 | R.styleable.SlideToActView_complete_icon,
439 | R.drawable.slidetoact_animated_ic_check,
440 | )
441 |
442 | mIconMargin =
443 | getDimensionPixelSize(
444 | R.styleable.SlideToActView_icon_margin,
445 | resources.getDimensionPixelSize(R.dimen.slidetoact_default_icon_margin),
446 | )
447 |
448 | mArrowMargin = mIconMargin
449 | mTickMargin = mIconMargin
450 |
451 | mIsCompleted = getBoolean(R.styleable.SlideToActView_state_complete, false)
452 |
453 | mStartBounceAnimation =
454 | getBoolean(
455 | R.styleable.SlideToActView_bounce_on_start,
456 | false,
457 | )
458 | mBounceAnimationDuration =
459 | getInteger(
460 | R.styleable.SlideToActView_bounce_duration,
461 | 2000,
462 | ).toLong()
463 | mBounceAnimationRepeat =
464 | getInteger(
465 | R.styleable.SlideToActView_bounce_repeat,
466 | ValueAnimator.INFINITE,
467 | )
468 | }
469 | } finally {
470 | attrs.recycle()
471 | }
472 |
473 | mInnerRect =
474 | RectF(
475 | (mActualAreaMargin + mEffectivePosition).toFloat(),
476 | mActualAreaMargin.toFloat(),
477 | (mAreaHeight + mEffectivePosition).toFloat() - mActualAreaMargin.toFloat(),
478 | mAreaHeight.toFloat() - mActualAreaMargin.toFloat(),
479 | )
480 |
481 | mOuterRect =
482 | RectF(
483 | mActualAreaWidth.toFloat(),
484 | 0f,
485 | mAreaWidth.toFloat() - mActualAreaWidth.toFloat(),
486 | mAreaHeight.toFloat(),
487 | )
488 |
489 | mDrawableTick = loadIconCompat(context, actualCompleteDrawable)
490 |
491 | mTextPaint.textAlign = Paint.Align.CENTER
492 |
493 | outerColor = actualOuterColor
494 | innerColor = actualInnerColor
495 | iconColor = actualIconColor
496 |
497 | // This outline provider force removal of shadow
498 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
499 | outlineProvider = SlideToActOutlineProvider()
500 | }
501 | if (mStartBounceAnimation) {
502 | startBounceAnimation(mBounceAnimationDuration, mBounceAnimationRepeat)
503 | }
504 |
505 | // Accessibility related setup
506 | isClickable = true
507 | accessibilityDelegate =
508 | object : AccessibilityDelegate() {
509 | override fun onInitializeAccessibilityNodeInfo(
510 | host: View,
511 | info: AccessibilityNodeInfo,
512 | ) {
513 | super.onInitializeAccessibilityNodeInfo(host, info)
514 | info.text = text
515 | info.className = this::class.java.name
516 | info.isClickable = true
517 | }
518 | }
519 | ViewCompat.replaceAccessibilityAction(
520 | this,
521 | AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK,
522 | context.getString(R.string.slidetoact_accessibility_action_description),
523 | ) { _, _ ->
524 | setCompleted(true, withAnimation = true)
525 | true
526 | }
527 | }
528 |
529 | override fun onMeasure(
530 | widthMeasureSpec: Int,
531 | heightMeasureSpec: Int,
532 | ) {
533 | val widthMode = MeasureSpec.getMode(widthMeasureSpec)
534 | val widthSize = MeasureSpec.getSize(widthMeasureSpec)
535 | val width: Int
536 |
537 | val heightMode = MeasureSpec.getMode(heightMeasureSpec)
538 | val heightSize = MeasureSpec.getSize(heightMeasureSpec)
539 | val height: Int
540 |
541 | width =
542 | when (widthMode) {
543 | MeasureSpec.EXACTLY -> widthSize
544 | MeasureSpec.AT_MOST -> Math.min(mDesiredSliderWidth, widthSize)
545 | MeasureSpec.UNSPECIFIED -> mDesiredSliderWidth
546 | else -> mDesiredSliderWidth
547 | }
548 |
549 | height =
550 | when (heightMode) {
551 | MeasureSpec.EXACTLY -> heightSize
552 | MeasureSpec.AT_MOST -> Math.min(mDesiredSliderHeight, heightSize)
553 | MeasureSpec.UNSPECIFIED -> mDesiredSliderHeight
554 | else -> mDesiredSliderHeight
555 | }
556 |
557 | setMeasuredDimension(width, height)
558 | }
559 |
560 | override fun onSizeChanged(
561 | w: Int,
562 | h: Int,
563 | oldw: Int,
564 | oldh: Int,
565 | ) {
566 | mAreaWidth = w
567 | mAreaHeight = h
568 | if (mBorderRadius == -1) {
569 | // Round if not set up
570 | mBorderRadius = h / 2
571 | }
572 |
573 | // Text horizontal/vertical positioning (both centered)
574 | mTextXPosition = mAreaWidth.toFloat() / 2
575 | mTextYPosition = (mAreaHeight.toFloat() / 2) -
576 | (mTextPaint.descent() + mTextPaint.ascent()) / 2
577 |
578 | // Make sure the position is recomputed.
579 | mPosition = 0
580 |
581 | // Set state to complete if needed
582 | setCompletedNotAnimated(mIsCompleted)
583 | }
584 |
585 | override fun onDraw(canvas: Canvas) {
586 | super.onDraw(canvas)
587 |
588 | // Outer area
589 | mOuterRect.set(
590 | mActualAreaWidth.toFloat(),
591 | 0f,
592 | mAreaWidth.toFloat() - mActualAreaWidth.toFloat(),
593 | mAreaHeight.toFloat(),
594 | )
595 | canvas.drawRoundRect(
596 | mOuterRect,
597 | mBorderRadius.toFloat(),
598 | mBorderRadius.toFloat(),
599 | mOuterPaint,
600 | )
601 |
602 | // Text alpha
603 | mTextPaint.alpha = (255 * mPositionPercInv).toInt()
604 | // Checking if the TextView has a Transformation method applied (e.g. AllCaps).
605 | val textToDraw = mTextView.transformationMethod?.getTransformation(text, mTextView) ?: text
606 | canvas.drawText(
607 | textToDraw,
608 | 0,
609 | textToDraw.length,
610 | mTextXPosition,
611 | mTextYPosition,
612 | mTextPaint,
613 | )
614 |
615 | // Inner Cursor
616 | // ratio is used to compute the proper border radius for the inner rect (see #8).
617 | val ratio = (mAreaHeight - 2 * mActualAreaMargin).toFloat() / mAreaHeight.toFloat()
618 | mInnerRect.set(
619 | (mActualAreaMargin + mEffectivePosition).toFloat(),
620 | mActualAreaMargin.toFloat(),
621 | (mAreaHeight + mEffectivePosition).toFloat() - mActualAreaMargin.toFloat(),
622 | mAreaHeight.toFloat() - mActualAreaMargin.toFloat(),
623 | )
624 | canvas.drawRoundRect(
625 | mInnerRect,
626 | mBorderRadius.toFloat() * ratio,
627 | mBorderRadius.toFloat() * ratio,
628 | mInnerPaint,
629 | )
630 |
631 | // Arrow angle
632 | // We compute the rotation of the arrow and we apply .rotate transformation on the canvas.
633 | canvas.save()
634 | if (isReversed) {
635 | canvas.scale(-1F, 1F, mInnerRect.centerX(), mInnerRect.centerY())
636 | }
637 | if (isRotateIcon) {
638 | mArrowAngle = -180 * mPositionPerc
639 | canvas.rotate(mArrowAngle, mInnerRect.centerX(), mInnerRect.centerY())
640 | }
641 | mDrawableArrow.setBounds(
642 | mInnerRect.left.toInt() + mArrowMargin,
643 | mInnerRect.top.toInt() + mArrowMargin,
644 | mInnerRect.right.toInt() - mArrowMargin,
645 | mInnerRect.bottom.toInt() - mArrowMargin,
646 | )
647 | if (mDrawableArrow.bounds.left <= mDrawableArrow.bounds.right &&
648 | mDrawableArrow.bounds.top <= mDrawableArrow.bounds.bottom
649 | ) {
650 | mDrawableArrow.draw(canvas)
651 | }
652 | canvas.restore()
653 |
654 | // Tick drawing
655 | mDrawableTick.setBounds(
656 | mActualAreaWidth + mTickMargin,
657 | mTickMargin,
658 | mAreaWidth - mTickMargin - mActualAreaWidth,
659 | mAreaHeight - mTickMargin,
660 | )
661 |
662 | tintIconCompat(mDrawableTick, innerColor)
663 | if (mFlagDrawTick) {
664 | mDrawableTick.draw(canvas)
665 | }
666 | }
667 |
668 | // Intentionally override `performClick` to do not lose accessibility support.
669 | @Suppress("RedundantOverride")
670 | override fun performClick(): Boolean {
671 | return super.performClick()
672 | }
673 |
674 | override fun onTouchEvent(event: MotionEvent?): Boolean {
675 | if (event != null && event.action == MotionEvent.ACTION_DOWN) {
676 | // Calling performClick on every ACTION_DOWN so OnClickListener is triggered properly.
677 | performClick()
678 | }
679 | stopBounceAnimation()
680 | if (event != null && isEnabled && mIsRespondingToTouchEvents) {
681 | when (event.action) {
682 | MotionEvent.ACTION_DOWN -> {
683 | if (checkInsideButton(event.x, event.y)) {
684 | mFlagMoving = true
685 | mLastX = event.x
686 | parent.requestDisallowInterceptTouchEvent(true)
687 | } else {
688 | // Clicking outside the area -> User failed, notify the listener.
689 | onSlideUserFailedListener?.onSlideFailed(this, true)
690 | }
691 | }
692 | MotionEvent.ACTION_UP -> {
693 | parent.requestDisallowInterceptTouchEvent(false)
694 | if ((mPosition > 0 && isLocked) ||
695 | (mPosition > 0 && mPositionPerc < mGraceValue)
696 | ) {
697 | // Check for grace value
698 | val positionAnimator = ValueAnimator.ofInt(mPosition, 0)
699 | positionAnimator.duration = animDuration
700 | positionAnimator.addUpdateListener {
701 | mPosition = it.animatedValue as Int
702 | invalidate()
703 | }
704 | positionAnimator.start()
705 | } else if (mPosition > 0 && mPositionPerc >= mGraceValue) {
706 | startAnimationComplete()
707 | } else if (mFlagMoving && mPosition == 0) {
708 | // mFlagMoving == true means user successfully grabbed the slider,
709 | // but mPosition == 0 means that the slider is released at the beginning
710 | // so either a Tap or the user slided back.
711 | onSlideUserFailedListener?.onSlideFailed(this, false)
712 | }
713 | mFlagMoving = false
714 | }
715 | MotionEvent.ACTION_MOVE -> {
716 | if (mFlagMoving) {
717 | // True if the cursor was not at the end position before this event
718 | val wasIncomplete = mPositionPerc < 1f
719 |
720 | val diffX = event.x - mLastX
721 | mLastX = event.x
722 | increasePosition(diffX.toInt())
723 | invalidate()
724 |
725 | // If this event brought the cursor to the end position, we can vibrate
726 | if (bumpVibration > 0 && wasIncomplete && mPositionPerc == 1f) {
727 | handleVibration()
728 | }
729 | }
730 | }
731 | }
732 | return true
733 | }
734 | return super.onTouchEvent(event)
735 | }
736 |
737 | /**
738 | * Private method to check if user has touched the slider cursor
739 | * @param x The x coordinate of the touch event
740 | * @param y The y coordinate of the touch event
741 | * @return A boolean that informs if user has pressed or not
742 | */
743 | private fun checkInsideButton(
744 | x: Float,
745 | y: Float,
746 | ): Boolean {
747 | return (
748 | 0 < y &&
749 | y < mAreaHeight &&
750 | mEffectivePosition < x &&
751 | x < (mAreaHeight + mEffectivePosition)
752 | )
753 | }
754 |
755 | /**
756 | * Private method for increasing/decreasing the position
757 | * Ensure that position never exits from its range [0, (mAreaWidth - mAreaHeight)].
758 | *
759 | * Please note that the increment is inverted in case of a reversed slider.
760 | *
761 | * @param inc Increment to be performed (negative if it's a decrement)
762 | */
763 | private fun increasePosition(inc: Int) {
764 | mPosition =
765 | if (isReversed) {
766 | mPosition - inc
767 | } else {
768 | mPosition + inc
769 | }
770 | if (mPosition < 0) {
771 | mPosition = 0
772 | }
773 | if (mPosition > (mAreaWidth - mAreaHeight)) {
774 | mPosition = mAreaWidth - mAreaHeight
775 | }
776 | }
777 |
778 | /**
779 | * Private method that is performed when user completes the slide
780 | */
781 | private fun startAnimationComplete() {
782 | val animSet = AnimatorSet()
783 |
784 | // Animator that moves the cursor
785 | val finalPositionAnimator = ValueAnimator.ofInt(mPosition, mAreaWidth - mAreaHeight)
786 | finalPositionAnimator.addUpdateListener {
787 | mPosition = it.animatedValue as Int
788 | invalidate()
789 | }
790 |
791 | // Animator that bounce away the cursors
792 | val marginAnimator =
793 | ValueAnimator.ofInt(
794 | mActualAreaMargin,
795 | (mInnerRect.width() / 2).toInt() + mActualAreaMargin,
796 | )
797 | marginAnimator.addUpdateListener {
798 | mActualAreaMargin = it.animatedValue as Int
799 | invalidate()
800 | }
801 | marginAnimator.interpolator = AnticipateOvershootInterpolator(2f)
802 |
803 | // Animator that reduces the outer area (to right)
804 | val areaAnimator = ValueAnimator.ofInt(0, (mAreaWidth - mAreaHeight) / 2)
805 | areaAnimator.addUpdateListener {
806 | mActualAreaWidth = it.animatedValue as Int
807 | if (Build.VERSION.SDK_INT >= 21) {
808 | invalidateOutline()
809 | }
810 | invalidate()
811 | }
812 |
813 | val tickListener =
814 | ValueAnimator.AnimatorUpdateListener {
815 | // We need to enable the drawing of the AnimatedVectorDrawable before starting it.
816 | if (!mFlagDrawTick) {
817 | mFlagDrawTick = true
818 | mTickMargin = mIconMargin
819 | }
820 | }
821 | val tickAnimator: ValueAnimator = createIconAnimator(this, mDrawableTick, tickListener)
822 |
823 | val animators = mutableListOf()
824 | if (mPosition < mAreaWidth - mAreaHeight) {
825 | animators.add(finalPositionAnimator)
826 | }
827 |
828 | if (isAnimateCompletion) {
829 | animators.add(marginAnimator)
830 | animators.add(areaAnimator)
831 | animators.add(tickAnimator)
832 | }
833 |
834 | animSet.playSequentially(*animators.toTypedArray())
835 |
836 | animSet.duration = animDuration
837 |
838 | animSet.addListener(
839 | object : Animator.AnimatorListener {
840 | override fun onAnimationStart(p0: Animator) {
841 | onSlideToActAnimationEventListener?.onSlideCompleteAnimationStarted(
842 | this@SlideToActView,
843 | mPositionPerc,
844 | )
845 | }
846 |
847 | override fun onAnimationCancel(p0: Animator) {
848 | }
849 |
850 | override fun onAnimationEnd(p0: Animator) {
851 | mIsCompleted = true
852 | onSlideToActAnimationEventListener?.onSlideCompleteAnimationEnded(
853 | this@SlideToActView,
854 | )
855 | onSlideCompleteListener?.onSlideComplete(this@SlideToActView)
856 | }
857 |
858 | override fun onAnimationRepeat(p0: Animator) {
859 | }
860 | },
861 | )
862 | mIsRespondingToTouchEvents = false
863 | animSet.start()
864 | }
865 |
866 | /** Private method to update view to base state */
867 | private fun setBaseState() {
868 | mPosition = 0
869 | mActualAreaMargin = mOriginAreaMargin
870 | mActualAreaWidth = 0
871 | mArrowMargin = mIconMargin
872 |
873 | mIsCompleted = false
874 | mIsRespondingToTouchEvents = true
875 | mFlagDrawTick = false
876 | }
877 |
878 | /**
879 | * Method for complete slider immediately without animation
880 | */
881 | private fun setCompletedNotAnimated(state: Boolean) {
882 | if (state) {
883 | setCompleteState()
884 | } else {
885 | setBaseState()
886 | }
887 | }
888 |
889 | /** Private method to update view to complete state */
890 | private fun setCompleteState() {
891 | mPosition = mAreaWidth - mAreaHeight
892 | mActualAreaMargin = mAreaHeight / 2
893 | mActualAreaWidth = mPosition / 2
894 | mIsCompleted = true
895 |
896 | startIconAnimation(mDrawableTick)
897 |
898 | mFlagDrawTick = true
899 | mTickMargin = mIconMargin
900 |
901 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
902 | invalidateOutline()
903 | }
904 | }
905 |
906 | private fun setCompletedAnimated(state: Boolean) {
907 | if (state) {
908 | if (!mIsCompleted) {
909 | startAnimationComplete()
910 | }
911 | } else {
912 | if (mIsCompleted) {
913 | startAnimationReset()
914 | }
915 | }
916 | }
917 |
918 | /**
919 | * Method to change slider state
920 | * @param completed - True for the completed state, False for the base state
921 | * @param withAnimation - True for full slide animation
922 | * Note: If you provide an animated source file for 'completeIcon' this animation will be run.
923 | */
924 | fun setCompleted(
925 | completed: Boolean,
926 | withAnimation: Boolean,
927 | ) {
928 | stopBounceAnimation()
929 | if (withAnimation) {
930 | setCompletedAnimated(completed)
931 | } else {
932 | setCompletedNotAnimated(completed)
933 | }
934 | }
935 |
936 | /**
937 | * @deprecated Method that completes the slider
938 | */
939 | @Deprecated(
940 | message = "Use setCompleted(completed: true, withAnimation: true) instead.",
941 | replaceWith = ReplaceWith("setCompleted(completed: true, withAnimation: true)"),
942 | )
943 | fun completeSlider() {
944 | stopBounceAnimation()
945 | if (!mIsCompleted) {
946 | startAnimationComplete()
947 | }
948 | }
949 |
950 | /**
951 | * @deprecated Method that resets the slider
952 | */
953 | @Deprecated(
954 | message = "Use setCompleted(completed: false, withAnimation: true) instead.",
955 | replaceWith = ReplaceWith("setCompleted(completed: false, withAnimation: true)"),
956 | )
957 | fun resetSlider() {
958 | stopBounceAnimation()
959 | if (mIsCompleted) {
960 | startAnimationReset()
961 | }
962 | }
963 |
964 | /**
965 | * Method that returns the 'mIsCompleted' flag
966 | * @return True if slider is in the Complete state
967 | */
968 | fun isCompleted(): Boolean {
969 | return this.mIsCompleted
970 | }
971 |
972 | /**
973 | * Bounce animation on the slider on start
974 | */
975 | private fun startBounceAnimation(
976 | duration: Long,
977 | repeatCount: Int,
978 | ) {
979 | bounceAnimator.apply {
980 | addUpdateListener {
981 | mPosition = it.animatedValue as Int
982 | invalidate()
983 | }
984 | setDuration(duration)
985 | setRepeatCount(repeatCount)
986 | repeatMode = ValueAnimator.RESTART
987 | startDelay = 1000
988 | start()
989 | }
990 | }
991 |
992 | private fun stopBounceAnimation() {
993 | if (bounceAnimator.isRunning) {
994 | bounceAnimator.end()
995 | }
996 | }
997 |
998 | /**
999 | * Private method that is performed when you want to reset the cursor
1000 | */
1001 | private fun startAnimationReset() {
1002 | mIsCompleted = false
1003 | val animSet = AnimatorSet()
1004 |
1005 | // Animator that reduces the tick size
1006 | val tickAnimator = ValueAnimator.ofInt(mTickMargin, mAreaWidth / 2)
1007 | tickAnimator.addUpdateListener {
1008 | mTickMargin = it.animatedValue as Int
1009 | invalidate()
1010 | }
1011 |
1012 | // Animator that enlarges the outer area
1013 | val areaAnimator = ValueAnimator.ofInt(mActualAreaWidth, 0)
1014 | areaAnimator.addUpdateListener {
1015 | // Now we can hide the tick till the next complete
1016 | mFlagDrawTick = false
1017 | mActualAreaWidth = it.animatedValue as Int
1018 | if (Build.VERSION.SDK_INT >= 21) {
1019 | invalidateOutline()
1020 | }
1021 | invalidate()
1022 | }
1023 |
1024 | val positionAnimator = ValueAnimator.ofInt(mPosition, 0)
1025 | positionAnimator.addUpdateListener {
1026 | mPosition = it.animatedValue as Int
1027 | invalidate()
1028 | }
1029 |
1030 | // Animator that re-draw the cursors
1031 | val marginAnimator = ValueAnimator.ofInt(mActualAreaMargin, mOriginAreaMargin)
1032 | marginAnimator.addUpdateListener {
1033 | mActualAreaMargin = it.animatedValue as Int
1034 | invalidate()
1035 | }
1036 | marginAnimator.interpolator = AnticipateOvershootInterpolator(2f)
1037 |
1038 | // Animator that makes the arrow appear
1039 | val arrowAnimator = ValueAnimator.ofInt(mArrowMargin, mIconMargin)
1040 | arrowAnimator.addUpdateListener {
1041 | mArrowMargin = it.animatedValue as Int
1042 | invalidate()
1043 | }
1044 |
1045 | marginAnimator.interpolator = OvershootInterpolator(2f)
1046 |
1047 | if (isAnimateCompletion) {
1048 | animSet.playSequentially(
1049 | tickAnimator,
1050 | areaAnimator,
1051 | positionAnimator,
1052 | marginAnimator,
1053 | arrowAnimator,
1054 | )
1055 | } else {
1056 | animSet.playSequentially(positionAnimator)
1057 | }
1058 |
1059 | animSet.duration = animDuration
1060 |
1061 | animSet.addListener(
1062 | object : Animator.AnimatorListener {
1063 | override fun onAnimationStart(p0: Animator) {
1064 | onSlideToActAnimationEventListener?.onSlideResetAnimationStarted(
1065 | this@SlideToActView,
1066 | )
1067 | }
1068 |
1069 | override fun onAnimationCancel(p0: Animator) {
1070 | }
1071 |
1072 | override fun onAnimationEnd(p0: Animator) {
1073 | mIsRespondingToTouchEvents = true
1074 | stopIconAnimation(mDrawableTick)
1075 | onSlideToActAnimationEventListener?.onSlideResetAnimationEnded(
1076 | this@SlideToActView,
1077 | )
1078 | onSlideResetListener?.onSlideReset(this@SlideToActView)
1079 | }
1080 |
1081 | override fun onAnimationRepeat(p0: Animator) {
1082 | }
1083 | },
1084 | )
1085 | animSet.start()
1086 | }
1087 |
1088 | /**
1089 | * Private method to handle vibration logic, called when the cursor it moved to the end of
1090 | * it's path.
1091 | */
1092 | @SuppressLint("MissingPermission")
1093 | private fun handleVibration() {
1094 | if (bumpVibration <= 0) return
1095 |
1096 | if (ContextCompat.checkSelfPermission(context, Manifest.permission.VIBRATE) !=
1097 | PackageManager.PERMISSION_GRANTED
1098 | ) {
1099 | Log.w(
1100 | TAG,
1101 | "bumpVibration is set but permissions are unavailable." +
1102 | "You must have the permission android.permission.VIBRATE in " +
1103 | "AndroidManifest.xml to use bumpVibration",
1104 | )
1105 | return
1106 | }
1107 |
1108 | val vibrator =
1109 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
1110 | val vibratorManager =
1111 | context
1112 | .getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
1113 | vibratorManager.defaultVibrator
1114 | } else {
1115 | @Suppress("DEPRECATION")
1116 | context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
1117 | }
1118 |
1119 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1120 | vibrator.vibrate(
1121 | VibrationEffect.createOneShot(bumpVibration, VibrationEffect.DEFAULT_AMPLITUDE),
1122 | )
1123 | } else {
1124 | @Suppress("DEPRECATION")
1125 | vibrator.vibrate(bumpVibration)
1126 | }
1127 | }
1128 |
1129 | /**
1130 | * Event handler for the SlideToActView animation events.
1131 | * This event handler can be used to react to animation events from the Slide,
1132 | * the event will be fired whenever an animation start/end.
1133 | */
1134 | interface OnSlideToActAnimationEventListener {
1135 | /**
1136 | * Called when the slide complete animation start. You can perform actions during the
1137 | * complete animations.
1138 | *
1139 | * @param view The SlideToActView who created the event
1140 | * @param threshold The mPosition (in percentage [0f,1f]) where the user has left the cursor
1141 | */
1142 | fun onSlideCompleteAnimationStarted(
1143 | view: SlideToActView,
1144 | threshold: Float,
1145 | )
1146 |
1147 | /**
1148 | * Called when the slide complete animation finish. At this point the slider is stuck in the
1149 | * center of the slider.
1150 | *
1151 | * @param view The SlideToActView who created the event
1152 | */
1153 | fun onSlideCompleteAnimationEnded(view: SlideToActView)
1154 |
1155 | /**
1156 | * Called when the slide reset animation start. You can perform actions during the reset
1157 | * animations.
1158 | *
1159 | * @param view The SlideToActView who created the event
1160 | */
1161 | fun onSlideResetAnimationStarted(view: SlideToActView)
1162 |
1163 | /**
1164 | * Called when the slide reset animation finish. At this point the slider will be in the
1165 | * ready on the left of the screen and user can interact with it.
1166 | *
1167 | * @param view The SlideToActView who created the event
1168 | */
1169 | fun onSlideResetAnimationEnded(view: SlideToActView)
1170 | }
1171 |
1172 | /**
1173 | * Event handler for the slide complete event.
1174 | * Use this handler to react to slide event
1175 | */
1176 | interface OnSlideCompleteListener {
1177 | /**
1178 | * Called when user performed the slide
1179 | * @param view The SlideToActView who created the event
1180 | */
1181 | fun onSlideComplete(view: SlideToActView)
1182 | }
1183 |
1184 | /**
1185 | * Event handler for the slide react event.
1186 | * Use this handler to inform the user that he can slide again.
1187 | */
1188 | interface OnSlideResetListener {
1189 | /**
1190 | * Called when slides is again available
1191 | * @param view The SlideToActView who created the event
1192 | */
1193 | fun onSlideReset(view: SlideToActView)
1194 | }
1195 |
1196 | /**
1197 | * Event handler for the user failure with the Widget.
1198 | * You can subscribe to this event to get notified when the user is wrongly
1199 | * interacting with the widget to eventually educate it:
1200 | *
1201 | * - The user clicked outside of the cursor
1202 | * - The user slided but left when the cursor was back to zero
1203 | *
1204 | * You can use this listener to show a Toast or other messages.
1205 | */
1206 | interface OnSlideUserFailedListener {
1207 | /**
1208 | * Called when user failed to interact with the slider slide
1209 | * @param view The SlideToActView who created the event
1210 | * @param isOutside True if user pressed outside the cursor
1211 | */
1212 | fun onSlideFailed(
1213 | view: SlideToActView,
1214 | isOutside: Boolean,
1215 | )
1216 | }
1217 |
1218 | /**
1219 | * Outline provider for the SlideToActView.
1220 | * This outline will suppress the shadow (till the moment when Android will support
1221 | * updatable Outlines).
1222 | */
1223 | @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
1224 | private inner class SlideToActOutlineProvider : ViewOutlineProvider() {
1225 | override fun getOutline(
1226 | view: View?,
1227 | outline: Outline?,
1228 | ) {
1229 | if (view == null || outline == null) return
1230 |
1231 | outline.setRoundRect(
1232 | mActualAreaWidth,
1233 | 0,
1234 | mAreaWidth - mActualAreaWidth,
1235 | mAreaHeight,
1236 | mBorderRadius.toFloat(),
1237 | )
1238 | }
1239 | }
1240 | }
1241 |
--------------------------------------------------------------------------------