mirror of
https://github.com/SimpleMobileTools/Simple-Clock.git
synced 2025-06-05 22:19:17 +02:00
timer UI improvements + bug fixes
- add page indicator - add default timer based on saved prefs when the database is created - post scolling to the first timer when a new timer is added
This commit is contained in:
@ -5,9 +5,12 @@ import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.simplemobiletools.clock.extensions.config
|
||||
import com.simplemobiletools.clock.helpers.Converters
|
||||
import com.simplemobiletools.clock.interfaces.TimerDao
|
||||
import com.simplemobiletools.clock.models.Timer
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@Database(entities = [Timer::class], version = 1)
|
||||
@TypeConverters(Converters::class)
|
||||
@ -23,6 +26,12 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
synchronized(AppDatabase::class) {
|
||||
if (db == null) {
|
||||
db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app.db")
|
||||
.addCallback(object : Callback() {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
super.onCreate(db)
|
||||
insertDefaultTimer(context)
|
||||
}
|
||||
})
|
||||
.build()
|
||||
db!!.openHelper.setWriteAheadLoggingEnabled(true)
|
||||
}
|
||||
@ -31,6 +40,25 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
return db!!
|
||||
}
|
||||
|
||||
private fun insertDefaultTimer(context: Context) {
|
||||
Executors.newSingleThreadScheduledExecutor().execute {
|
||||
val config = context.config
|
||||
db!!.TimerDao().insertOrUpdateTimer(
|
||||
Timer(
|
||||
id = null,
|
||||
seconds = config.timerSeconds,
|
||||
state = config.timerState,
|
||||
vibrate = config.timerVibrate,
|
||||
soundUri = config.timerSoundUri,
|
||||
soundTitle = config.timerSoundTitle,
|
||||
label = config.timerLabel ?: "",
|
||||
createdAt = System.currentTimeMillis(),
|
||||
channelId = config.timerChannelId,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun destroyInstance() {
|
||||
db = null
|
||||
}
|
||||
|
@ -53,10 +53,20 @@ class TimerFragment : Fragment() {
|
||||
timer_view_pager.setPageTransformer { _, _ -> }
|
||||
timer_view_pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
Log.i(TAG, "onPageSelected: $position")
|
||||
updateViews(position)
|
||||
indicator_view.setCurrentPosition(0)
|
||||
}
|
||||
})
|
||||
|
||||
activity?.let {
|
||||
val textColor = it.config.textColor
|
||||
indicator_view.setSelectedDotColor(textColor)
|
||||
indicator_view.setDotColor(textColor.adjustAlpha(0.5f))
|
||||
indicator_view.attachToPager(timer_view_pager)
|
||||
}
|
||||
|
||||
|
||||
timer_add.setOnClickListener {
|
||||
activity?.hideKeyboard(it)
|
||||
activity?.timerHelper?.insertNewTimer {
|
||||
@ -101,14 +111,12 @@ class TimerFragment : Fragment() {
|
||||
|
||||
private fun updateViews(position: Int) {
|
||||
activity?.runOnUiThread {
|
||||
if (timerAdapter.itemCount > 0) {
|
||||
if (timerAdapter.itemCount > position) {
|
||||
val timer = timerAdapter.getItemAt(position)
|
||||
updateViewStates(timer.state)
|
||||
view.timer_play_pause.beVisible()
|
||||
} else {
|
||||
view.timer_delete.beGone()
|
||||
view.timer_play_pause.beGone()
|
||||
view.timer_reset.beGone()
|
||||
Log.e(TAG, "updateViews: position $position is greater than adapter itemCount ${timerAdapter.itemCount}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -117,15 +125,18 @@ class TimerFragment : Fragment() {
|
||||
activity?.timerHelper?.getTimers { timers ->
|
||||
Log.d(TAG, "refreshTimers: $timers")
|
||||
timerAdapter.submitList(timers) {
|
||||
Log.e(TAG, "submitted list: timerPositionToScrollTo=$timerPositionToScrollTo")
|
||||
if (timerPositionToScrollTo != INVALID_POSITION && timerAdapter.itemCount > timerPositionToScrollTo) {
|
||||
Log.e(TAG, "scrolling to position=$timerPositionToScrollTo")
|
||||
view.timer_view_pager.setCurrentItem(timerPositionToScrollTo, false)
|
||||
timerPositionToScrollTo = INVALID_POSITION
|
||||
} else if (scrollToLatest) {
|
||||
view.timer_view_pager.setCurrentItem(0, false)
|
||||
view.timer_view_pager.post {
|
||||
Log.e(TAG, "submitted list: timerPositionToScrollTo=$timerPositionToScrollTo")
|
||||
if (timerPositionToScrollTo != INVALID_POSITION && timerAdapter.itemCount > timerPositionToScrollTo) {
|
||||
Log.e(TAG, "scrolling to position=$timerPositionToScrollTo")
|
||||
view.timer_view_pager.setCurrentItem(timerPositionToScrollTo, false)
|
||||
timerPositionToScrollTo = INVALID_POSITION
|
||||
} else if (scrollToLatest) {
|
||||
Log.e(TAG, "scrolling to latest")
|
||||
view.timer_view_pager.setCurrentItem(0, false)
|
||||
}
|
||||
updateViews(timer_view_pager.currentItem)
|
||||
}
|
||||
updateViews(timer_view_pager.currentItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,10 @@
|
||||
package com.simplemobiletools.clock.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.media.RingtoneManager
|
||||
import android.util.Log
|
||||
import com.simplemobiletools.clock.extensions.config
|
||||
import com.simplemobiletools.clock.extensions.timerDb
|
||||
import com.simplemobiletools.clock.models.Timer
|
||||
import com.simplemobiletools.clock.models.TimerState
|
||||
import com.simplemobiletools.commons.extensions.getDefaultAlarmSound
|
||||
import com.simplemobiletools.commons.extensions.getDefaultAlarmTitle
|
||||
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
|
||||
|
||||
class TimerHelper(val context: Context) {
|
||||
@ -42,17 +39,18 @@ class TimerHelper(val context: Context) {
|
||||
|
||||
fun insertNewTimer(callback: () -> Unit = {}) {
|
||||
ensureBackgroundThread {
|
||||
val config = context.config
|
||||
timerDao.insertOrUpdateTimer(
|
||||
Timer(
|
||||
id = null,
|
||||
// seconds = DEFAULT_TIME,
|
||||
seconds = 5,
|
||||
TimerState.Idle,
|
||||
false,
|
||||
context.getDefaultAlarmSound(RingtoneManager.TYPE_ALARM).uri,
|
||||
context.getDefaultAlarmTitle(RingtoneManager.TYPE_ALARM),
|
||||
"",
|
||||
System.currentTimeMillis(),
|
||||
seconds = config.timerSeconds,
|
||||
state = config.timerState,
|
||||
vibrate = config.timerVibrate,
|
||||
soundUri = config.timerSoundUri,
|
||||
soundTitle = config.timerSoundTitle,
|
||||
label = config.timerLabel ?: "",
|
||||
createdAt = System.currentTimeMillis(),
|
||||
channelId = config.timerChannelId,
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -0,0 +1,577 @@
|
||||
package com.simplemobiletools.clock.views.pageindicator
|
||||
|
||||
import android.animation.ArgbEvaluator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.util.AttributeSet
|
||||
import android.util.SparseArray
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.IntDef
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.simplemobiletools.clock.R
|
||||
|
||||
/**
|
||||
* Page indicator that scrolls to selected item based on
|
||||
* [PagerIndicator](https://github.com/TinkoffCreditSystems/PagerIndicator)
|
||||
* */
|
||||
class PagerIndicator @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = R.attr.scrollingPagerIndicatorStyle
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
@IntDef(RecyclerView.HORIZONTAL, RecyclerView.VERTICAL)
|
||||
annotation class Orientation
|
||||
|
||||
private var infiniteDotCount = 0
|
||||
private val dotMinimumSize: Int
|
||||
private val dotNormalSize: Int
|
||||
private val dotSelectedSize: Int
|
||||
private val spaceBetweenDotCenters: Int
|
||||
private var visibleDotCount = 0
|
||||
private var visibleDotThreshold: Int
|
||||
private var orientation: Int
|
||||
private var visibleFramePosition = 0f
|
||||
private var visibleFrameWidth = 0f
|
||||
private var firstDotOffset = 0f
|
||||
private var dotScale: SparseArray<Float>? = null
|
||||
private var itemCount = 0
|
||||
private val paint: Paint
|
||||
private val colorEvaluator = ArgbEvaluator()
|
||||
|
||||
@ColorInt
|
||||
private var dotColor: Int
|
||||
|
||||
@ColorInt
|
||||
private var selectedDotColor: Int
|
||||
private var looped: Boolean
|
||||
private var attachRunnable: Runnable? = null
|
||||
private var currentAttacher: PagerAttacher<*>? = null
|
||||
private var dotCountInitialized = false
|
||||
|
||||
/**
|
||||
* Sets dot count
|
||||
*
|
||||
* @param count new dot count
|
||||
*/
|
||||
var dotCount: Int
|
||||
get() = if (looped && itemCount > visibleDotCount) {
|
||||
infiniteDotCount
|
||||
} else {
|
||||
itemCount
|
||||
}
|
||||
set(count) {
|
||||
initDots(count)
|
||||
}
|
||||
|
||||
init {
|
||||
val attributes = context.obtainStyledAttributes(
|
||||
attrs,
|
||||
R.styleable.PagerIndicator,
|
||||
defStyleAttr,
|
||||
R.style.PagerIndicator)
|
||||
dotColor = attributes.getColor(R.styleable.PagerIndicator_spi_dotColor, 0)
|
||||
selectedDotColor =
|
||||
attributes.getColor(R.styleable.PagerIndicator_spi_dotSelectedColor, dotColor)
|
||||
dotNormalSize =
|
||||
attributes.getDimensionPixelSize(R.styleable.PagerIndicator_spi_dotSize, 0)
|
||||
dotSelectedSize =
|
||||
attributes.getDimensionPixelSize(R.styleable.PagerIndicator_spi_dotSelectedSize,
|
||||
0)
|
||||
val dotMinimumSize =
|
||||
attributes.getDimensionPixelSize(R.styleable.PagerIndicator_spi_dotMinimumSize,
|
||||
-1)
|
||||
this.dotMinimumSize = if (dotMinimumSize <= dotNormalSize) dotMinimumSize else -1
|
||||
spaceBetweenDotCenters =
|
||||
attributes.getDimensionPixelSize(R.styleable.PagerIndicator_spi_dotSpacing,
|
||||
0) + dotNormalSize
|
||||
looped = attributes.getBoolean(R.styleable.PagerIndicator_spi_looped, false)
|
||||
val visibleDotCount =
|
||||
attributes.getInt(R.styleable.PagerIndicator_spi_visibleDotCount, 5)
|
||||
setVisibleDotCount(visibleDotCount)
|
||||
visibleDotThreshold =
|
||||
attributes.getInt(R.styleable.PagerIndicator_spi_visibleDotThreshold, 2)
|
||||
orientation = attributes.getInt(R.styleable.PagerIndicator_spi_orientation,
|
||||
RecyclerView.HORIZONTAL)
|
||||
attributes.recycle()
|
||||
paint = Paint()
|
||||
paint.isAntiAlias = true
|
||||
if (isInEditMode) {
|
||||
dotCount = visibleDotCount
|
||||
onPageScrolled(visibleDotCount / 2, 0f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* You should make indicator looped in your PagerAttacher implementation if your custom pager is looped too
|
||||
* If pager has less items than visible_dot_count, indicator will work as usual;
|
||||
* otherwise it will always be in infinite state.
|
||||
*
|
||||
* @param looped true if pager is looped
|
||||
*/
|
||||
fun setLooped(looped: Boolean) {
|
||||
this.looped = looped
|
||||
reattach()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return not selected dot color
|
||||
*/
|
||||
@ColorInt
|
||||
fun getDotColor(): Int {
|
||||
return dotColor
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets dot color
|
||||
*
|
||||
* @param color dot color
|
||||
*/
|
||||
fun setDotColor(@ColorInt color: Int) {
|
||||
dotColor = color
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the selected dot color
|
||||
*/
|
||||
@ColorInt
|
||||
fun getSelectedDotColor(): Int {
|
||||
return selectedDotColor
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets selected dot color
|
||||
*
|
||||
* @param color selected dot color
|
||||
*/
|
||||
fun setSelectedDotColor(@ColorInt color: Int) {
|
||||
selectedDotColor = color
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum number of dots which will be visible at the same time.
|
||||
* If pager has more pages than visible_dot_count, indicator will scroll to show extra dots.
|
||||
* Must be odd number.
|
||||
*
|
||||
* @return visible dot count
|
||||
*/
|
||||
fun getVisibleDotCount(): Int {
|
||||
return visibleDotCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets visible dot count. Maximum number of dots which will be visible at the same time.
|
||||
* If pager has more pages than visible_dot_count, indicator will scroll to show extra dots.
|
||||
* Must be odd number.
|
||||
*
|
||||
* @param visibleDotCount visible dot count
|
||||
* @throws IllegalStateException when pager is already attached
|
||||
*/
|
||||
fun setVisibleDotCount(visibleDotCount: Int) {
|
||||
require(visibleDotCount % 2 != 0) { "visibleDotCount must be odd" }
|
||||
this.visibleDotCount = visibleDotCount
|
||||
infiniteDotCount = visibleDotCount + 2
|
||||
if (attachRunnable != null) {
|
||||
reattach()
|
||||
} else {
|
||||
requestLayout()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimum number of dots which should be visible.
|
||||
* If pager has less pages than visibleDotThreshold, no dots will be shown.
|
||||
*
|
||||
* @return visible dot threshold.
|
||||
*/
|
||||
fun getVisibleDotThreshold(): Int {
|
||||
return visibleDotThreshold
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the minimum number of dots which should be visible.
|
||||
* If pager has less pages than visibleDotThreshold, no dots will be shown.
|
||||
*
|
||||
* @param visibleDotThreshold visible dot threshold.
|
||||
*/
|
||||
fun setVisibleDotThreshold(visibleDotThreshold: Int) {
|
||||
this.visibleDotThreshold = visibleDotThreshold
|
||||
if (attachRunnable != null) {
|
||||
reattach()
|
||||
} else {
|
||||
requestLayout()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible orientation of the dots
|
||||
*
|
||||
* @return dot orientation (RecyclerView.HORIZONTAL, RecyclerView.VERTICAL)
|
||||
*/
|
||||
@Orientation
|
||||
fun getOrientation(): Int {
|
||||
return orientation
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the dot orientation
|
||||
*
|
||||
* @param orientation dot orientation (RecyclerView.HORIZONTAL, RecyclerView.VERTICAL)
|
||||
*/
|
||||
fun setOrientation(@Orientation orientation: Int) {
|
||||
this.orientation = orientation
|
||||
if (attachRunnable != null) {
|
||||
reattach()
|
||||
} else {
|
||||
requestLayout()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches indicator to ViewPager2
|
||||
*
|
||||
* @param pager pager to attach
|
||||
*/
|
||||
fun attachToPager(pager: ViewPager2) {
|
||||
attachToPager<ViewPager2>(pager, ViewPager2Attacher())
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches to any custom pager
|
||||
*
|
||||
* @param pager pager to attach
|
||||
* @param attacher helper which should setup this indicator to work with custom pager
|
||||
*/
|
||||
fun <T> attachToPager(pager: T, attacher: PagerAttacher<T>) {
|
||||
detachFromPager()
|
||||
attacher.attachToPager(this, pager)
|
||||
currentAttacher = attacher
|
||||
attachRunnable = Runnable {
|
||||
itemCount = -1
|
||||
attachToPager(pager, attacher)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detaches indicator from pager.
|
||||
*/
|
||||
fun detachFromPager() {
|
||||
if (currentAttacher != null) {
|
||||
currentAttacher!!.detachFromPager()
|
||||
currentAttacher = null
|
||||
attachRunnable = null
|
||||
}
|
||||
dotCountInitialized = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Detaches indicator from pager and attaches it again.
|
||||
* It may be useful for refreshing after adapter count change.
|
||||
*/
|
||||
fun reattach() {
|
||||
if (attachRunnable != null) {
|
||||
attachRunnable!!.run()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method must be called from ViewPager.OnPageChangeListener.onPageScrolled or from some
|
||||
* similar callback if you use custom PagerAttacher.
|
||||
*
|
||||
* @param page index of the first page currently being displayed
|
||||
* Page position+1 will be visible if offset is nonzero
|
||||
* @param offset Value from [0, 1) indicating the offset from the page at position
|
||||
*/
|
||||
fun onPageScrolled(page: Int, offset: Float) {
|
||||
require(!(offset < 0 || offset > 1)) { "Offset must be [0, 1]" }
|
||||
if (page < 0 || page != 0 && page >= itemCount) {
|
||||
throw IndexOutOfBoundsException("page must be [0, adapter.getItemCount())")
|
||||
}
|
||||
if (!looped || itemCount <= visibleDotCount && itemCount > 1) {
|
||||
dotScale!!.clear()
|
||||
if (orientation == LinearLayout.HORIZONTAL) {
|
||||
scaleDotByOffset(page, offset)
|
||||
if (page < itemCount - 1) {
|
||||
scaleDotByOffset(page + 1, 1 - offset)
|
||||
} else if (itemCount > 1) {
|
||||
scaleDotByOffset(0, 1 - offset)
|
||||
}
|
||||
} else { // Vertical orientation
|
||||
scaleDotByOffset(page, offset)
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
if (orientation == LinearLayout.HORIZONTAL) {
|
||||
adjustFramePosition(offset, page)
|
||||
} else {
|
||||
adjustFramePosition(offset, page - 1)
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets currently selected position (according to your pager's adapter)
|
||||
*
|
||||
* @param position new current position
|
||||
*/
|
||||
fun setCurrentPosition(position: Int) {
|
||||
if (position != 0 && (position < 0 || position >= itemCount)) {
|
||||
throw IndexOutOfBoundsException("Position must be [0, adapter.getItemCount()]")
|
||||
}
|
||||
if (itemCount == 0) {
|
||||
return
|
||||
}
|
||||
adjustFramePosition(0f, position)
|
||||
updateScaleInIdleState(position)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
// Width
|
||||
val measuredWidth: Int
|
||||
// Height
|
||||
val measuredHeight: Int
|
||||
if (orientation == LinearLayoutManager.HORIZONTAL) {
|
||||
// We ignore widthMeasureSpec because width is based on visibleDotCount
|
||||
measuredWidth = if (isInEditMode) {
|
||||
// Maximum width with all dots visible
|
||||
(visibleDotCount - 1) * spaceBetweenDotCenters + dotSelectedSize
|
||||
} else {
|
||||
if (itemCount >= visibleDotCount) visibleFrameWidth.toInt() else (itemCount - 1) * spaceBetweenDotCenters + dotSelectedSize
|
||||
}
|
||||
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
|
||||
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
|
||||
|
||||
// Height
|
||||
val desiredHeight = dotSelectedSize
|
||||
measuredHeight = when (heightMode) {
|
||||
MeasureSpec.EXACTLY -> heightSize
|
||||
MeasureSpec.AT_MOST -> desiredHeight.coerceAtMost(heightSize)
|
||||
MeasureSpec.UNSPECIFIED -> desiredHeight
|
||||
else -> desiredHeight
|
||||
}
|
||||
} else {
|
||||
measuredHeight = if (isInEditMode) {
|
||||
(visibleDotCount - 1) * spaceBetweenDotCenters + dotSelectedSize
|
||||
} else {
|
||||
if (itemCount >= visibleDotCount) visibleFrameWidth.toInt() else (itemCount) * spaceBetweenDotCenters + dotSelectedSize
|
||||
}
|
||||
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
|
||||
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
|
||||
|
||||
// Width
|
||||
val desiredWidth = dotSelectedSize
|
||||
measuredWidth = when (widthMode) {
|
||||
MeasureSpec.EXACTLY -> widthSize
|
||||
MeasureSpec.AT_MOST -> desiredWidth.coerceAtMost(widthSize)
|
||||
MeasureSpec.UNSPECIFIED -> desiredWidth
|
||||
else -> desiredWidth
|
||||
}
|
||||
}
|
||||
setMeasuredDimension(measuredWidth, measuredHeight)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val dotCount = dotCount
|
||||
if (dotCount < visibleDotThreshold) {
|
||||
return
|
||||
}
|
||||
|
||||
// Some empirical coefficients
|
||||
val scaleDistance = (spaceBetweenDotCenters + (dotSelectedSize - dotNormalSize) / 2) * 0.7f
|
||||
val smallScaleDistance = (dotSelectedSize / 2).toFloat()
|
||||
val centerScaleDistance = 6f / 7f * spaceBetweenDotCenters
|
||||
val firstVisibleDotPos =
|
||||
(visibleFramePosition - firstDotOffset).toInt() / spaceBetweenDotCenters
|
||||
var lastVisibleDotPos = (firstVisibleDotPos
|
||||
+ (visibleFramePosition + visibleFrameWidth - getDotOffsetAt(firstVisibleDotPos)).toInt()
|
||||
/ spaceBetweenDotCenters)
|
||||
|
||||
// If real dots count is less than we can draw inside visible frame, we move lastVisibleDotPos
|
||||
// to the last item
|
||||
if (firstVisibleDotPos == 0 && lastVisibleDotPos + 1 > dotCount) {
|
||||
lastVisibleDotPos = dotCount - 1
|
||||
}
|
||||
for (i in firstVisibleDotPos..lastVisibleDotPos) {
|
||||
val dot = getDotOffsetAt(i)
|
||||
if (dot >= visibleFramePosition && dot < visibleFramePosition + visibleFrameWidth) {
|
||||
var diameter: Float
|
||||
var scale: Float
|
||||
|
||||
// Calculate scale according to current page position
|
||||
scale = if (looped && itemCount > visibleDotCount) {
|
||||
val frameCenter = visibleFramePosition + visibleFrameWidth / 2
|
||||
if (dot >= frameCenter - centerScaleDistance
|
||||
&& dot <= frameCenter
|
||||
) {
|
||||
(dot - frameCenter + centerScaleDistance) / centerScaleDistance
|
||||
} else if (dot > frameCenter
|
||||
&& dot < frameCenter + centerScaleDistance
|
||||
) {
|
||||
1 - (dot - frameCenter) / centerScaleDistance
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
} else {
|
||||
getDotScaleAt(i)
|
||||
}
|
||||
diameter = dotNormalSize + (dotSelectedSize - dotNormalSize) * scale
|
||||
|
||||
// Additional scale for dots at corners
|
||||
if (itemCount > visibleDotCount) {
|
||||
val currentScaleDistance = if (!looped && (i == 0 || i == dotCount - 1)) {
|
||||
smallScaleDistance
|
||||
} else {
|
||||
scaleDistance
|
||||
}
|
||||
var size = width
|
||||
if (orientation == LinearLayoutManager.VERTICAL) {
|
||||
size = height
|
||||
}
|
||||
if (dot - visibleFramePosition < currentScaleDistance) {
|
||||
val calculatedDiameter =
|
||||
diameter * (dot - visibleFramePosition) / currentScaleDistance
|
||||
if (calculatedDiameter <= dotMinimumSize) {
|
||||
diameter = dotMinimumSize.toFloat()
|
||||
} else if (calculatedDiameter < diameter) {
|
||||
diameter = calculatedDiameter
|
||||
}
|
||||
} else if (dot - visibleFramePosition > size - currentScaleDistance) {
|
||||
val calculatedDiameter =
|
||||
diameter * (-dot + visibleFramePosition + size) / currentScaleDistance
|
||||
if (calculatedDiameter <= dotMinimumSize) {
|
||||
diameter = dotMinimumSize.toFloat()
|
||||
} else if (calculatedDiameter < diameter) {
|
||||
diameter = calculatedDiameter
|
||||
}
|
||||
}
|
||||
}
|
||||
paint.color = calculateDotColor(scale)
|
||||
if (orientation == LinearLayoutManager.HORIZONTAL) {
|
||||
canvas.drawCircle(dot - visibleFramePosition, (
|
||||
measuredHeight / 2).toFloat(),
|
||||
diameter / 2,
|
||||
paint)
|
||||
} else {
|
||||
canvas.drawCircle((measuredWidth / 2).toFloat(),
|
||||
dot - visibleFramePosition,
|
||||
diameter / 2,
|
||||
paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
private fun calculateDotColor(dotScale: Float): Int {
|
||||
return colorEvaluator.evaluate(dotScale, dotColor, selectedDotColor) as Int
|
||||
}
|
||||
|
||||
private fun updateScaleInIdleState(currentPos: Int) {
|
||||
if (!looped || itemCount < visibleDotCount) {
|
||||
dotScale!!.clear()
|
||||
dotScale!!.put(currentPos, 1f)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initDots(itemCount: Int) {
|
||||
if (this.itemCount == itemCount && dotCountInitialized) {
|
||||
return
|
||||
}
|
||||
this.itemCount = itemCount
|
||||
dotCountInitialized = true
|
||||
dotScale = SparseArray()
|
||||
if (itemCount < visibleDotThreshold) {
|
||||
requestLayout()
|
||||
invalidate()
|
||||
return
|
||||
}
|
||||
firstDotOffset =
|
||||
if (looped && this.itemCount > visibleDotCount) 0f else dotSelectedSize / 2.toFloat()
|
||||
visibleFrameWidth =
|
||||
((visibleDotCount - 1) * spaceBetweenDotCenters + dotSelectedSize).toFloat()
|
||||
requestLayout()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun adjustFramePosition(offset: Float, pos: Int) {
|
||||
if (itemCount <= visibleDotCount) {
|
||||
// Without scroll
|
||||
visibleFramePosition = 0f
|
||||
} else if (!looped && itemCount > visibleDotCount) {
|
||||
// Not looped with scroll
|
||||
val center = getDotOffsetAt(pos) + spaceBetweenDotCenters * offset
|
||||
visibleFramePosition = center - visibleFrameWidth / 2
|
||||
|
||||
// Block frame offset near start and end
|
||||
val firstCenteredDotIndex = visibleDotCount / 2
|
||||
val lastCenteredDot = getDotOffsetAt(dotCount - 1 - firstCenteredDotIndex)
|
||||
if (visibleFramePosition + visibleFrameWidth / 2 < getDotOffsetAt(firstCenteredDotIndex)) {
|
||||
visibleFramePosition = getDotOffsetAt(firstCenteredDotIndex) - visibleFrameWidth / 2
|
||||
} else if (visibleFramePosition + visibleFrameWidth / 2 > lastCenteredDot) {
|
||||
visibleFramePosition = lastCenteredDot - visibleFrameWidth / 2
|
||||
}
|
||||
} else {
|
||||
// Looped with scroll
|
||||
val center = getDotOffsetAt(infiniteDotCount / 2) + spaceBetweenDotCenters * offset
|
||||
visibleFramePosition = center - visibleFrameWidth / 2
|
||||
}
|
||||
}
|
||||
|
||||
private fun scaleDotByOffset(position: Int, offset: Float) {
|
||||
if (dotScale == null || dotCount == 0) {
|
||||
return
|
||||
}
|
||||
setDotScaleAt(position, 1 - Math.abs(offset))
|
||||
}
|
||||
|
||||
private fun getDotOffsetAt(index: Int): Float {
|
||||
return firstDotOffset + index * spaceBetweenDotCenters
|
||||
}
|
||||
|
||||
private fun getDotScaleAt(index: Int): Float {
|
||||
val scale = dotScale!![index]
|
||||
return scale ?: 0f
|
||||
}
|
||||
|
||||
private fun setDotScaleAt(index: Int, scale: Float) {
|
||||
if (scale == 0f) {
|
||||
dotScale!!.remove(index)
|
||||
} else {
|
||||
dotScale!!.put(index, scale)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for attaching to custom pagers.
|
||||
*
|
||||
* @param <T> custom pager's class
|
||||
</T> */
|
||||
interface PagerAttacher<T> {
|
||||
/**
|
||||
* Here you should add all needed callbacks to track pager's item count, position and offset
|
||||
* You must call:
|
||||
* [PagerIndicator.setDotCount] - initially and after page selection,
|
||||
* [PagerIndicator.setCurrentPosition] - initially and after page selection,
|
||||
* [PagerIndicator.onPageScrolled] - in your pager callback to track scroll offset,
|
||||
* [PagerIndicator.reattach] - each time your adapter items change.
|
||||
*
|
||||
* @param indicator indicator
|
||||
* @param pager pager to attach
|
||||
*/
|
||||
fun attachToPager(indicator: PagerIndicator, pager: T)
|
||||
|
||||
/**
|
||||
* Here you should unregister all callbacks previously added to pager and adapter
|
||||
*/
|
||||
fun detachFromPager()
|
||||
}
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
package com.simplemobiletools.clock.views.pageindicator
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
|
||||
|
||||
/**
|
||||
* ViewPager2 Attacher for [PagerIndicator]
|
||||
) */
|
||||
class ViewPager2Attacher : PagerIndicator.PagerAttacher<ViewPager2> {
|
||||
private var dataSetObserver: AdapterDataObserver? = null
|
||||
private var attachedAdapter: RecyclerView.Adapter<*>? = null
|
||||
private var onPageChangeListener: OnPageChangeCallback? = null
|
||||
private var pager: ViewPager2? = null
|
||||
|
||||
override fun attachToPager(indicator: PagerIndicator, pager: ViewPager2) {
|
||||
attachedAdapter = pager.adapter
|
||||
checkNotNull(attachedAdapter) { "Set adapter before call attachToPager() method" }
|
||||
this.pager = pager
|
||||
updateIndicatorDotsAndPosition(indicator)
|
||||
dataSetObserver = object : AdapterDataObserver() {
|
||||
override fun onChanged() {
|
||||
indicator.reattach()
|
||||
}
|
||||
}
|
||||
attachedAdapter!!.registerAdapterDataObserver(dataSetObserver!!)
|
||||
onPageChangeListener = object : OnPageChangeCallback() {
|
||||
var idleState = true
|
||||
override fun onPageScrolled(
|
||||
position: Int,
|
||||
positionOffset: Float,
|
||||
positionOffsetPixel: Int
|
||||
) {
|
||||
updateIndicatorOnPagerScrolled(indicator, position, positionOffset)
|
||||
}
|
||||
|
||||
override fun onPageSelected(position: Int) {
|
||||
if (idleState) {
|
||||
updateIndicatorDotsAndPosition(indicator)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageScrollStateChanged(state: Int) {
|
||||
idleState = state == ViewPager2.SCROLL_STATE_IDLE
|
||||
}
|
||||
}
|
||||
pager.registerOnPageChangeCallback(onPageChangeListener!!)
|
||||
}
|
||||
|
||||
override fun detachFromPager() {
|
||||
attachedAdapter!!.unregisterAdapterDataObserver(dataSetObserver!!)
|
||||
pager!!.unregisterOnPageChangeCallback(onPageChangeListener!!)
|
||||
}
|
||||
|
||||
private fun updateIndicatorDotsAndPosition(indicator: PagerIndicator) {
|
||||
indicator.dotCount = attachedAdapter!!.itemCount
|
||||
indicator.setCurrentPosition(pager!!.currentItem)
|
||||
}
|
||||
|
||||
private fun updateIndicatorOnPagerScrolled(
|
||||
indicator: PagerIndicator,
|
||||
position: Int,
|
||||
positionOffset: Float
|
||||
) {
|
||||
// ViewPager may emit negative positionOffset for very fast scrolling
|
||||
val offset: Float = when {
|
||||
positionOffset < 0 -> {
|
||||
0f
|
||||
}
|
||||
positionOffset > 1 -> {
|
||||
1f
|
||||
}
|
||||
else -> {
|
||||
positionOffset
|
||||
}
|
||||
}
|
||||
indicator.onPageScrolled(position, offset)
|
||||
}
|
||||
}
|
@ -8,15 +8,27 @@
|
||||
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/timer_view_pager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginEnd="@dimen/medium_margin"
|
||||
android:layout_marginBottom="@dimen/medium_margin"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintBottom_toTopOf="@id/timer_play_pause"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/indicator_view"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<com.simplemobiletools.clock.views.pageindicator.PagerIndicator
|
||||
android:id="@+id/indicator_view"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="@dimen/medium_margin"
|
||||
app:layout_constraintBottom_toTopOf="@id/timer_play_pause"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0.05"
|
||||
app:spi_orientation="vertical" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/timer_reset"
|
||||
android:layout_width="@dimen/stopwatch_button_small_size"
|
||||
|
21
app/src/main/res/values/attrs.xml
Normal file
21
app/src/main/res/values/attrs.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<attr name="scrollingPagerIndicatorStyle"/>
|
||||
|
||||
<declare-styleable name="PagerIndicator">
|
||||
<attr name="spi_dotColor" format="color" />
|
||||
<attr name="spi_dotSelectedColor" format="color" />
|
||||
<attr name="spi_dotSize" format="dimension" />
|
||||
<attr name="spi_dotSelectedSize" format="dimension" />
|
||||
<attr name="spi_dotMinimumSize" format="dimension" />
|
||||
<attr name="spi_dotSpacing" format="dimension" />
|
||||
<attr name="spi_visibleDotCount" format="integer" />
|
||||
<attr name="spi_visibleDotThreshold" format="integer" />
|
||||
<attr name="spi_looped" format="boolean" />
|
||||
<attr name="spi_orientation" format="enum">
|
||||
<enum name="horizontal" value="0" />
|
||||
<enum name="vertical" value="1" />
|
||||
</attr>
|
||||
</declare-styleable>
|
||||
</resources>
|
@ -2,4 +2,14 @@
|
||||
|
||||
<style name="AppTheme" parent="AppTheme.Base"/>
|
||||
|
||||
<style name="PagerIndicator">
|
||||
<item name="spi_dotColor">@android:color/darker_gray</item>
|
||||
<item name="spi_dotSelectedColor">@android:color/darker_gray</item>
|
||||
<item name="spi_dotSize">6dp</item>
|
||||
<item name="spi_dotSelectedSize">10dp</item>
|
||||
<item name="spi_dotSpacing">8dp</item>
|
||||
<item name="spi_visibleDotCount">5</item>
|
||||
<item name="spi_visibleDotThreshold">2</item>
|
||||
<item name="spi_looped">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
Reference in New Issue
Block a user