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.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.TypeConverters
|
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.helpers.Converters
|
||||||
import com.simplemobiletools.clock.interfaces.TimerDao
|
import com.simplemobiletools.clock.interfaces.TimerDao
|
||||||
import com.simplemobiletools.clock.models.Timer
|
import com.simplemobiletools.clock.models.Timer
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@Database(entities = [Timer::class], version = 1)
|
@Database(entities = [Timer::class], version = 1)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
@ -23,6 +26,12 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
synchronized(AppDatabase::class) {
|
synchronized(AppDatabase::class) {
|
||||||
if (db == null) {
|
if (db == null) {
|
||||||
db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app.db")
|
db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app.db")
|
||||||
|
.addCallback(object : Callback() {
|
||||||
|
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||||
|
super.onCreate(db)
|
||||||
|
insertDefaultTimer(context)
|
||||||
|
}
|
||||||
|
})
|
||||||
.build()
|
.build()
|
||||||
db!!.openHelper.setWriteAheadLoggingEnabled(true)
|
db!!.openHelper.setWriteAheadLoggingEnabled(true)
|
||||||
}
|
}
|
||||||
@ -31,6 +40,25 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
return db!!
|
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() {
|
fun destroyInstance() {
|
||||||
db = null
|
db = null
|
||||||
}
|
}
|
||||||
|
@ -53,10 +53,20 @@ class TimerFragment : Fragment() {
|
|||||||
timer_view_pager.setPageTransformer { _, _ -> }
|
timer_view_pager.setPageTransformer { _, _ -> }
|
||||||
timer_view_pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
timer_view_pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||||
override fun onPageSelected(position: Int) {
|
override fun onPageSelected(position: Int) {
|
||||||
|
Log.i(TAG, "onPageSelected: $position")
|
||||||
updateViews(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 {
|
timer_add.setOnClickListener {
|
||||||
activity?.hideKeyboard(it)
|
activity?.hideKeyboard(it)
|
||||||
activity?.timerHelper?.insertNewTimer {
|
activity?.timerHelper?.insertNewTimer {
|
||||||
@ -101,14 +111,12 @@ class TimerFragment : Fragment() {
|
|||||||
|
|
||||||
private fun updateViews(position: Int) {
|
private fun updateViews(position: Int) {
|
||||||
activity?.runOnUiThread {
|
activity?.runOnUiThread {
|
||||||
if (timerAdapter.itemCount > 0) {
|
if (timerAdapter.itemCount > position) {
|
||||||
val timer = timerAdapter.getItemAt(position)
|
val timer = timerAdapter.getItemAt(position)
|
||||||
updateViewStates(timer.state)
|
updateViewStates(timer.state)
|
||||||
view.timer_play_pause.beVisible()
|
view.timer_play_pause.beVisible()
|
||||||
} else {
|
} else {
|
||||||
view.timer_delete.beGone()
|
Log.e(TAG, "updateViews: position $position is greater than adapter itemCount ${timerAdapter.itemCount}")
|
||||||
view.timer_play_pause.beGone()
|
|
||||||
view.timer_reset.beGone()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -117,18 +125,21 @@ class TimerFragment : Fragment() {
|
|||||||
activity?.timerHelper?.getTimers { timers ->
|
activity?.timerHelper?.getTimers { timers ->
|
||||||
Log.d(TAG, "refreshTimers: $timers")
|
Log.d(TAG, "refreshTimers: $timers")
|
||||||
timerAdapter.submitList(timers) {
|
timerAdapter.submitList(timers) {
|
||||||
|
view.timer_view_pager.post {
|
||||||
Log.e(TAG, "submitted list: timerPositionToScrollTo=$timerPositionToScrollTo")
|
Log.e(TAG, "submitted list: timerPositionToScrollTo=$timerPositionToScrollTo")
|
||||||
if (timerPositionToScrollTo != INVALID_POSITION && timerAdapter.itemCount > timerPositionToScrollTo) {
|
if (timerPositionToScrollTo != INVALID_POSITION && timerAdapter.itemCount > timerPositionToScrollTo) {
|
||||||
Log.e(TAG, "scrolling to position=$timerPositionToScrollTo")
|
Log.e(TAG, "scrolling to position=$timerPositionToScrollTo")
|
||||||
view.timer_view_pager.setCurrentItem(timerPositionToScrollTo, false)
|
view.timer_view_pager.setCurrentItem(timerPositionToScrollTo, false)
|
||||||
timerPositionToScrollTo = INVALID_POSITION
|
timerPositionToScrollTo = INVALID_POSITION
|
||||||
} else if (scrollToLatest) {
|
} else if (scrollToLatest) {
|
||||||
|
Log.e(TAG, "scrolling to latest")
|
||||||
view.timer_view_pager.setCurrentItem(0, false)
|
view.timer_view_pager.setCurrentItem(0, false)
|
||||||
}
|
}
|
||||||
updateViews(timer_view_pager.currentItem)
|
updateViews(timer_view_pager.currentItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun stopTimer(timer: Timer) {
|
private fun stopTimer(timer: Timer) {
|
||||||
EventBus.getDefault().post(TimerEvent.Reset(timer.id!!, timer.seconds.secondsToMillis))
|
EventBus.getDefault().post(TimerEvent.Reset(timer.id!!, timer.seconds.secondsToMillis))
|
||||||
|
@ -1,13 +1,10 @@
|
|||||||
package com.simplemobiletools.clock.helpers
|
package com.simplemobiletools.clock.helpers
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.RingtoneManager
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.simplemobiletools.clock.extensions.config
|
||||||
import com.simplemobiletools.clock.extensions.timerDb
|
import com.simplemobiletools.clock.extensions.timerDb
|
||||||
import com.simplemobiletools.clock.models.Timer
|
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
|
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
|
||||||
|
|
||||||
class TimerHelper(val context: Context) {
|
class TimerHelper(val context: Context) {
|
||||||
@ -42,17 +39,18 @@ class TimerHelper(val context: Context) {
|
|||||||
|
|
||||||
fun insertNewTimer(callback: () -> Unit = {}) {
|
fun insertNewTimer(callback: () -> Unit = {}) {
|
||||||
ensureBackgroundThread {
|
ensureBackgroundThread {
|
||||||
|
val config = context.config
|
||||||
timerDao.insertOrUpdateTimer(
|
timerDao.insertOrUpdateTimer(
|
||||||
Timer(
|
Timer(
|
||||||
id = null,
|
id = null,
|
||||||
// seconds = DEFAULT_TIME,
|
seconds = config.timerSeconds,
|
||||||
seconds = 5,
|
state = config.timerState,
|
||||||
TimerState.Idle,
|
vibrate = config.timerVibrate,
|
||||||
false,
|
soundUri = config.timerSoundUri,
|
||||||
context.getDefaultAlarmSound(RingtoneManager.TYPE_ALARM).uri,
|
soundTitle = config.timerSoundTitle,
|
||||||
context.getDefaultAlarmTitle(RingtoneManager.TYPE_ALARM),
|
label = config.timerLabel ?: "",
|
||||||
"",
|
createdAt = System.currentTimeMillis(),
|
||||||
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
|
<androidx.viewpager2.widget.ViewPager2
|
||||||
android:id="@+id/timer_view_pager"
|
android:id="@+id/timer_view_pager"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginEnd="@dimen/medium_margin"
|
||||||
android:layout_marginBottom="@dimen/medium_margin"
|
android:layout_marginBottom="@dimen/medium_margin"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
app:layout_constraintBottom_toTopOf="@id/timer_play_pause"
|
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_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="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
|
<ImageView
|
||||||
android:id="@+id/timer_reset"
|
android:id="@+id/timer_reset"
|
||||||
android:layout_width="@dimen/stopwatch_button_small_size"
|
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="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>
|
</resources>
|
||||||
|
Reference in New Issue
Block a user