Rename
This commit is contained in:
1 parent
b86d90021c
commit
6c0757c91b
972 files changed
+6885
-7321
No files matched your search
@@ -0,0 +1,122 @@
|
||||
package io.legato.kazusa.ui.widget
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.Typeface
|
||||
import android.os.Build
|
||||
import android.text.StaticLayout
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.utils.canvasrecorder.CanvasRecorderFactory
|
||||
import io.legato.kazusa.utils.canvasrecorder.recordIfNeededThenDraw
|
||||
import io.legato.kazusa.utils.dpToPx
|
||||
|
||||
class BatteryView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppCompatTextView(context, attrs) {
|
||||
private val batteryTypeface by lazy {
|
||||
Typeface.createFromAsset(context.assets, "font/number.ttf")
|
||||
}
|
||||
private val batteryPaint = Paint()
|
||||
private val outFrame = Rect()
|
||||
private val polar = Rect()
|
||||
private val canvasRecorder = CanvasRecorderFactory.create()
|
||||
var isBattery = false
|
||||
set(value) {
|
||||
field = value
|
||||
if (value && !isInEditMode) {
|
||||
super.setTypeface(batteryTypeface)
|
||||
postInvalidate()
|
||||
}
|
||||
}
|
||||
private var battery: Int = 0
|
||||
|
||||
init {
|
||||
setPadding(4.dpToPx(), 3.dpToPx(), 6.dpToPx(), 3.dpToPx())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
isFallbackLineSpacing = false
|
||||
}
|
||||
batteryPaint.strokeWidth = 1f.dpToPx()
|
||||
batteryPaint.isAntiAlias = true
|
||||
batteryPaint.color = paint.color
|
||||
}
|
||||
|
||||
override fun setTypeface(tf: Typeface?) {
|
||||
if (!isBattery) {
|
||||
super.setTypeface(tf)
|
||||
}
|
||||
}
|
||||
|
||||
fun setColor(@ColorInt color: Int) {
|
||||
setTextColor(color)
|
||||
batteryPaint.color = color
|
||||
invalidate()
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
fun setBattery(battery: Int, text: String? = null) {
|
||||
this.battery = battery
|
||||
if (text.isNullOrEmpty()) {
|
||||
setText(battery.toString())
|
||||
} else {
|
||||
setText("$text $battery")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
canvasRecorder.invalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (AppConfig.optimizeRender) {
|
||||
canvasRecorder.recordIfNeededThenDraw(canvas, width, height) {
|
||||
super.onDraw(this)
|
||||
drawBattery(this)
|
||||
}
|
||||
} else {
|
||||
super.onDraw(canvas)
|
||||
drawBattery(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawBattery(canvas: Canvas) {
|
||||
if (!isBattery) return
|
||||
layout.getLineBounds(0, outFrame)
|
||||
val batteryStart = layout
|
||||
.getPrimaryHorizontal(text.length - battery.toString().length)
|
||||
.toInt() + 2.dpToPx()
|
||||
val batteryEnd = batteryStart +
|
||||
StaticLayout.getDesiredWidth(battery.toString(), paint).toInt() + 4.dpToPx()
|
||||
outFrame.set(
|
||||
batteryStart,
|
||||
2.dpToPx(),
|
||||
batteryEnd,
|
||||
height - 2.dpToPx()
|
||||
)
|
||||
val dj = (outFrame.bottom - outFrame.top) / 3
|
||||
polar.set(
|
||||
batteryEnd,
|
||||
outFrame.top + dj,
|
||||
batteryEnd + 2.dpToPx(),
|
||||
outFrame.bottom - dj
|
||||
)
|
||||
batteryPaint.style = Paint.Style.STROKE
|
||||
canvas.drawRect(outFrame, batteryPaint)
|
||||
batteryPaint.style = Paint.Style.FILL
|
||||
canvas.drawRect(polar, batteryPaint)
|
||||
}
|
||||
|
||||
@Suppress("UNNECESSARY_SAFE_CALL")
|
||||
override fun invalidate() {
|
||||
super.invalidate()
|
||||
canvasRecorder?.invalidate()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package io.legato.kazusa.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.SeekBar
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import com.google.android.material.slider.Slider
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.databinding.ViewDetailSeekBarBinding
|
||||
//import io.legado.app.lib.theme.bottomBackground
|
||||
//import io.legado.app.lib.theme.getPrimaryTextColor
|
||||
import io.legato.kazusa.ui.widget.seekbar.SeekBarChangeListener
|
||||
|
||||
|
||||
class DetailSeekBar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs),
|
||||
SeekBarChangeListener {
|
||||
private var binding: ViewDetailSeekBarBinding =
|
||||
ViewDetailSeekBarBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
private val isBottomBackground: Boolean
|
||||
|
||||
var valueFormat: ((progress: Int) -> String)? = null
|
||||
var onChanged: ((progress: Int) -> Unit)? = null
|
||||
|
||||
var progress: Int
|
||||
get() = binding.slider.value.toInt()
|
||||
set(value) {
|
||||
binding.slider.value = value.toFloat()
|
||||
upValue()
|
||||
}
|
||||
|
||||
var max: Int
|
||||
get() = binding.slider.valueTo.toInt()
|
||||
set(value) {
|
||||
binding.slider.valueTo = value.toFloat()
|
||||
}
|
||||
|
||||
init {
|
||||
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.DetailSeekBar)
|
||||
isBottomBackground =
|
||||
typedArray.getBoolean(R.styleable.DetailSeekBar_isBottomBackground, false)
|
||||
val title = typedArray.getText(R.styleable.DetailSeekBar_title)
|
||||
binding.tvSeekTitle.apply {
|
||||
text = title
|
||||
TooltipCompat.setTooltipText(this, title)
|
||||
}
|
||||
binding.slider.valueTo = typedArray.getInteger(R.styleable.DetailSeekBar_max, 0).toFloat()
|
||||
typedArray.recycle()
|
||||
if (isBottomBackground && !isInEditMode) {
|
||||
// val isLight = ColorUtils.isColorLight(context.bottomBackground)
|
||||
// val textColor = context.getPrimaryTextColor(isLight)
|
||||
// binding.tvSeekTitle.setTextColor(textColor)
|
||||
// binding.ivSeekPlus.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
|
||||
// binding.ivSeekReduce.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
|
||||
// binding.tvSeekValue.setTextColor(textColor)
|
||||
}
|
||||
binding.ivSeekPlus.setOnClickListener {
|
||||
binding.slider.value = binding.slider.value + 1
|
||||
onChanged?.invoke(binding.slider.value.toInt())
|
||||
}
|
||||
|
||||
binding.ivSeekReduce.setOnClickListener {
|
||||
binding.slider.value = binding.slider.value - 1
|
||||
onChanged?.invoke(binding.slider.value.toInt())
|
||||
}
|
||||
|
||||
binding.slider.addOnChangeListener { _, value, fromUser ->
|
||||
upValue(value.toInt())
|
||||
}
|
||||
|
||||
binding.slider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
|
||||
override fun onStartTrackingTouch(slider: Slider) {
|
||||
// 可以添加开始拖动时的逻辑
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(slider: Slider) {
|
||||
onChanged?.invoke(slider.value.toInt())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun upValue(progress: Int = binding.slider.value.toInt()) {
|
||||
valueFormat?.let {
|
||||
binding.tvSeekValue.text = it.invoke(progress)
|
||||
} ?: let {
|
||||
binding.tvSeekValue.text = progress.toString()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
|
||||
upValue(progress)
|
||||
}
|
||||
|
||||
// override fun onStartTrackingTouch(seekBar: SeekBar) {
|
||||
//
|
||||
// }
|
||||
//
|
||||
// override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
// onChanged?.invoke(binding.slider.progress)
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package io.legato.kazusa.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipGroup
|
||||
import com.google.android.material.color.MaterialColors
|
||||
|
||||
@Suppress("unused", "MemberVisibilityCanBePrivate")
|
||||
class LabelsBar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : ChipGroup(context, attrs) {
|
||||
|
||||
private val unUsedChips = arrayListOf<Chip>()
|
||||
private val usedChips = arrayListOf<Chip>()
|
||||
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
fun setLabels(labels: List<String>) {
|
||||
clear()
|
||||
labels.forEach {
|
||||
addLabel(it)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
fun setLabels(labels: Array<String>) {
|
||||
setLabels(labels.toList())
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
unUsedChips.addAll(usedChips)
|
||||
usedChips.clear()
|
||||
removeAllViews()
|
||||
}
|
||||
|
||||
fun addLabel(label: String) {
|
||||
val chip = if (unUsedChips.isEmpty()) {
|
||||
Chip(context).apply {
|
||||
isClickable = false
|
||||
isCheckable = false
|
||||
chipStartPadding = 8f
|
||||
chipEndPadding = 8f
|
||||
chipStrokeWidth = 0f
|
||||
|
||||
chipBackgroundColor = ColorStateList.valueOf(
|
||||
MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimaryContainer, Color.LTGRAY)
|
||||
)
|
||||
|
||||
|
||||
usedChips.add(this)
|
||||
}
|
||||
} else {
|
||||
unUsedChips.removeAt(unUsedChips.lastIndex).also {
|
||||
usedChips.add(it)
|
||||
}
|
||||
}
|
||||
chip.text = label
|
||||
addView(chip)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package io.legato.kazusa.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.core.widget.NestedScrollView
|
||||
|
||||
class NoChildScrollNestedScrollView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : NestedScrollView(context, attrs) {
|
||||
|
||||
override fun requestChildRectangleOnScreen(
|
||||
child: View,
|
||||
rectangle: Rect?,
|
||||
immediate: Boolean
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package io.legato.kazusa.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import android.widget.PopupWindow
|
||||
import io.legato.kazusa.base.adapter.ItemViewHolder
|
||||
import io.legato.kazusa.base.adapter.RecyclerAdapter
|
||||
import io.legato.kazusa.databinding.ItemTextBinding
|
||||
import io.legato.kazusa.databinding.PopupActionBinding
|
||||
import io.legato.kazusa.lib.dialogs.SelectItem
|
||||
import splitties.systemservices.layoutInflater
|
||||
|
||||
class PopupAction(private val context: Context) :
|
||||
PopupWindow(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) {
|
||||
|
||||
val binding = PopupActionBinding.inflate(context.layoutInflater)
|
||||
val adapter by lazy {
|
||||
Adapter(context).apply {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
}
|
||||
var onActionClick: ((action: String) -> Unit)? = null
|
||||
|
||||
init {
|
||||
contentView = binding.root
|
||||
|
||||
isTouchable = true
|
||||
isOutsideTouchable = false
|
||||
isFocusable = true
|
||||
|
||||
binding.recyclerView.adapter = adapter
|
||||
}
|
||||
|
||||
fun setItems(items: List<SelectItem<String>>) {
|
||||
adapter.setItems(items)
|
||||
}
|
||||
|
||||
inner class Adapter(context: Context) :
|
||||
RecyclerAdapter<SelectItem<String>, ItemTextBinding>(context) {
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return position.toLong()
|
||||
}
|
||||
|
||||
override fun getViewBinding(parent: ViewGroup): ItemTextBinding {
|
||||
return ItemTextBinding.inflate(inflater, parent, false)
|
||||
}
|
||||
|
||||
override fun convert(
|
||||
holder: ItemViewHolder,
|
||||
binding: ItemTextBinding,
|
||||
item: SelectItem<String>,
|
||||
payloads: MutableList<Any>
|
||||
) {
|
||||
with(binding) {
|
||||
textView.text = item.title
|
||||
}
|
||||
}
|
||||
|
||||
override fun registerListener(holder: ItemViewHolder, binding: ItemTextBinding) {
|
||||
holder.itemView.setOnClickListener {
|
||||
getItem(holder.layoutPosition)?.let { item ->
|
||||
onActionClick?.invoke(item.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package io.legato.kazusa.ui.widget
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.use
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import io.legato.kazusa.utils.dpToPx
|
||||
import io.legato.kazusa.utils.setOnApplyWindowInsetsListenerCompat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import kotlin.math.min
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class ReaderInfoBarView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
companion object {
|
||||
const val ALIGN_LEFT = 0
|
||||
const val ALIGN_CENTER = 1
|
||||
}
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val textBounds = Rect()
|
||||
private val timeFormat = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT)
|
||||
private val timeReceiver = TimeReceiver()
|
||||
private var insetLeft: Int = 0
|
||||
private var insetRight: Int = 0
|
||||
private var insetTop: Int = 0
|
||||
private var cutoutInsetLeft = 0
|
||||
private var cutoutInsetRight = 0
|
||||
private val colorText = ColorUtils.setAlphaComponent(
|
||||
context.obtainStyledAttributes(intArrayOf(materialR.attr.colorOnSurface)).use {
|
||||
it.getColor(0, Color.BLACK)
|
||||
},
|
||||
200,
|
||||
)
|
||||
private val colorOutline = ColorUtils.setAlphaComponent(
|
||||
context.obtainStyledAttributes(intArrayOf(materialR.attr.colorSurface)).use {
|
||||
it.getColor(0, Color.WHITE)
|
||||
},
|
||||
200,
|
||||
)
|
||||
|
||||
var textInfoAlignment: Int = ALIGN_CENTER
|
||||
set(value) {
|
||||
field = value
|
||||
updateTextSize()
|
||||
invalidate()
|
||||
}
|
||||
private var timeText = timeFormat.format(Date())
|
||||
private var text: String = ""
|
||||
private val innerHeight
|
||||
get() = height - paddingTop - paddingBottom - insetTop
|
||||
private val innerWidth
|
||||
get() = width - paddingLeft - paddingRight - insetLeft - insetRight
|
||||
|
||||
init {
|
||||
val insetStart = 10.dpToPx()
|
||||
val insetEnd = 10.dpToPx()
|
||||
paint.strokeWidth = 2f.dpToPx()
|
||||
paint.setShadowLayer(2f, 1f, 1f, Color.GRAY)
|
||||
insetLeft = insetStart
|
||||
insetRight = insetEnd
|
||||
insetTop = minOf(insetLeft, insetRight)
|
||||
setOnApplyWindowInsetsListenerCompat { _, windowInsets ->
|
||||
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
if (insets.left >= paddingLeft) {
|
||||
cutoutInsetLeft = insets.left
|
||||
}
|
||||
if (insets.right >= paddingRight) {
|
||||
cutoutInsetRight = insets.right
|
||||
}
|
||||
windowInsets
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
val ty = innerHeight / 2f + textBounds.height() / 2f - textBounds.bottom
|
||||
|
||||
val textX = when (textInfoAlignment) {
|
||||
ALIGN_CENTER -> {
|
||||
val textWidth = paint.measureText(text)
|
||||
(width / 2f).coerceIn(
|
||||
paddingLeft + insetLeft + cutoutInsetLeft + textWidth / 2,
|
||||
width - paddingRight - insetRight - cutoutInsetRight - textWidth / 2
|
||||
)
|
||||
}
|
||||
|
||||
else -> (paddingLeft + insetLeft + cutoutInsetLeft).toFloat()
|
||||
}
|
||||
paint.textAlign = when (textInfoAlignment) {
|
||||
ALIGN_CENTER -> Paint.Align.CENTER
|
||||
else -> Paint.Align.LEFT
|
||||
}
|
||||
|
||||
canvas.drawTextOutline(
|
||||
text,
|
||||
textX,
|
||||
paddingTop + insetTop + ty,
|
||||
)
|
||||
|
||||
paint.textAlign = Paint.Align.RIGHT
|
||||
canvas.drawTextOutline(
|
||||
timeText,
|
||||
(width - paddingRight - insetRight - cutoutInsetRight).toFloat(),
|
||||
paddingTop + insetTop + ty,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
updateTextSize()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
ContextCompat.registerReceiver(
|
||||
context,
|
||||
timeReceiver,
|
||||
IntentFilter(Intent.ACTION_TIME_TICK),
|
||||
ContextCompat.RECEIVER_EXPORTED,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
context.unregisterReceiver(timeReceiver)
|
||||
}
|
||||
|
||||
fun update(label: String) {
|
||||
text = label
|
||||
updateTextSize()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun updateTextSize() {
|
||||
val testTextSize = 48f
|
||||
paint.textSize = testTextSize
|
||||
paint.getTextBounds(text, 0, text.length, textBounds)
|
||||
|
||||
val maxTextHeight = innerHeight * 0.8f
|
||||
val scaleFactor = min(
|
||||
maxTextHeight / textBounds.height(),
|
||||
calculateMaxWidthScale()
|
||||
)
|
||||
paint.textSize = testTextSize * scaleFactor
|
||||
|
||||
paint.getTextBounds(text, 0, text.length, textBounds)
|
||||
}
|
||||
|
||||
private fun calculateMaxWidthScale(): Float {
|
||||
return when (textInfoAlignment) {
|
||||
ALIGN_CENTER -> {
|
||||
val availableWidth = innerWidth - cutoutInsetLeft - cutoutInsetRight
|
||||
val requiredWidth = paint.measureText(text)
|
||||
if (requiredWidth > availableWidth) availableWidth / requiredWidth else 1f
|
||||
}
|
||||
|
||||
else -> 1f
|
||||
}
|
||||
}
|
||||
|
||||
private fun Canvas.drawTextOutline(text: String, x: Float, y: Float) {
|
||||
paint.color = colorOutline
|
||||
paint.style = Paint.Style.STROKE
|
||||
drawText(text, x, y, paint)
|
||||
paint.color = colorText
|
||||
paint.style = Paint.Style.FILL
|
||||
drawText(text, x, y, paint)
|
||||
}
|
||||
|
||||
private inner class TimeReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
timeText = timeFormat.format(Date())
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package io.legato.kazusa.ui.widget
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.SearchableInfo
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.style.ImageSpan
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
import androidx.core.graphics.withTranslation
|
||||
|
||||
|
||||
class SearchView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : SearchView(context, attrs) {
|
||||
private var mSearchHintIcon: Drawable? = null
|
||||
private var textView: TextView? = null
|
||||
|
||||
@SuppressLint("UseCompatLoadingForDrawables")
|
||||
override fun onLayout(
|
||||
changed: Boolean,
|
||||
left: Int,
|
||||
top: Int,
|
||||
right: Int,
|
||||
bottom: Int
|
||||
) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
try {
|
||||
if (textView == null) {
|
||||
textView = findViewById(androidx.appcompat.R.id.search_src_text)
|
||||
//mSearchHintIcon = this.context.getDrawable(R.drawable.ic_search_hint)
|
||||
}
|
||||
// 改变字体
|
||||
//textView!!.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
|
||||
textView!!.gravity = Gravity.CENTER_VERTICAL
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
textView!!.isLocalePreferredLineHeightForMinimumUsed = false
|
||||
}
|
||||
updateQueryHint()
|
||||
} catch (e: Exception) {
|
||||
e.printOnDebug()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDecoratedHint(hintText: CharSequence): CharSequence {
|
||||
// If the field is always expanded or we don't have a search hint icon,
|
||||
// then don't add the search icon to the hint.
|
||||
if (mSearchHintIcon == null) {
|
||||
return hintText
|
||||
}
|
||||
val textSize = textView!!.textSize.toInt()
|
||||
mSearchHintIcon!!.setBounds(0, 0, textSize, textSize)
|
||||
val ssb = SpannableStringBuilder(" ")
|
||||
ssb.setSpan(CenteredImageSpan(mSearchHintIcon), 1, 2, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
ssb.append(hintText)
|
||||
return ssb
|
||||
}
|
||||
|
||||
private fun updateQueryHint() {
|
||||
textView?.let {
|
||||
it.hint = getDecoratedHint(queryHint ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
override fun setIconifiedByDefault(iconified: Boolean) {
|
||||
super.setIconifiedByDefault(iconified)
|
||||
updateQueryHint()
|
||||
}
|
||||
|
||||
override fun setSearchableInfo(searchable: SearchableInfo?) {
|
||||
super.setSearchableInfo(searchable)
|
||||
searchable?.let {
|
||||
updateQueryHint()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setQueryHint(hint: CharSequence?) {
|
||||
super.setQueryHint(hint)
|
||||
updateQueryHint()
|
||||
}
|
||||
|
||||
internal class CenteredImageSpan(drawable: Drawable?) : ImageSpan(drawable!!) {
|
||||
override fun draw(
|
||||
canvas: Canvas, text: CharSequence,
|
||||
start: Int, end: Int, x: Float,
|
||||
top: Int, y: Int, bottom: Int, paint: Paint
|
||||
) {
|
||||
// image to draw
|
||||
val b = drawable
|
||||
// font metrics of text to be replaced
|
||||
val fm = paint.fontMetricsInt
|
||||
val transY = ((y + fm.descent + y + fm.ascent) / 2
|
||||
- b.bounds.bottom / 2)
|
||||
canvas.withTranslation(x, transY.toFloat()) {
|
||||
b.draw(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package io.legato.kazusa.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.MenuRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.databinding.ViewSelectActionBarBinding
|
||||
//import io.legado.app.lib.theme.accentColor
|
||||
//import io.legado.app.lib.theme.bottomBackground
|
||||
//import io.legado.app.lib.theme.getPrimaryTextColor
|
||||
//import io.legado.app.lib.theme.getSecondaryDisabledTextColor
|
||||
import io.legato.kazusa.utils.applyNavigationBarPadding
|
||||
import io.legato.kazusa.utils.visible
|
||||
|
||||
|
||||
@Suppress("unused")
|
||||
class SelectActionBar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
|
||||
// private val bgIsLight = ColorUtils.isColorLight(context.bottomBackground)
|
||||
// private val primaryTextColor = context.getPrimaryTextColor(bgIsLight)
|
||||
// private val disabledColor = context.getSecondaryDisabledTextColor(bgIsLight)
|
||||
|
||||
private var callBack: CallBack? = null
|
||||
private var selMenu: PopupMenu? = null
|
||||
private val binding = ViewSelectActionBarBinding
|
||||
.inflate(LayoutInflater.from(context), this, true)
|
||||
|
||||
init {
|
||||
if (!isInEditMode) {
|
||||
// setBackgroundColor(context.bottomBackground)
|
||||
// //elevation = context.elevation
|
||||
// binding.cbSelectedAll.setTextColor(primaryTextColor)
|
||||
// TintHelper.setTint(binding.cbSelectedAll, context.accentColor, !bgIsLight)
|
||||
// binding.ivMenuMore.setColorFilter(disabledColor, PorterDuff.Mode.SRC_IN)
|
||||
binding.cbSelectedAll.setOnCheckedChangeListener { buttonView, isChecked ->
|
||||
if (buttonView.isPressed) {
|
||||
callBack?.selectAll(isChecked)
|
||||
}
|
||||
}
|
||||
binding.btnRevertSelection.setOnClickListener { callBack?.revertSelection() }
|
||||
binding.btnSelectActionMain.setOnClickListener { callBack?.onClickSelectBarMainAction() }
|
||||
binding.ivMenuMore.setOnClickListener { selMenu?.show() }
|
||||
applyNavigationBarPadding()
|
||||
}
|
||||
}
|
||||
|
||||
fun setMainActionText(text: String) = binding.run {
|
||||
btnSelectActionMain.text = text
|
||||
btnSelectActionMain.visible()
|
||||
}
|
||||
|
||||
fun setMainActionText(@StringRes id: Int) = binding.run {
|
||||
btnSelectActionMain.setText(id)
|
||||
btnSelectActionMain.visible()
|
||||
}
|
||||
|
||||
fun inflateMenu(@MenuRes resId: Int): Menu? {
|
||||
selMenu = PopupMenu(context, binding.ivMenuMore)
|
||||
selMenu?.inflate(resId)
|
||||
binding.ivMenuMore.visible()
|
||||
return selMenu?.menu
|
||||
}
|
||||
|
||||
fun setCallBack(callBack: CallBack) {
|
||||
this.callBack = callBack
|
||||
}
|
||||
|
||||
fun setOnMenuItemClickListener(listener: PopupMenu.OnMenuItemClickListener) {
|
||||
selMenu?.setOnMenuItemClickListener(listener)
|
||||
}
|
||||
|
||||
fun upCountView(selectCount: Int, allCount: Int) = binding.run {
|
||||
if (selectCount == 0) {
|
||||
cbSelectedAll.isChecked = false
|
||||
} else {
|
||||
cbSelectedAll.isChecked = selectCount >= allCount
|
||||
}
|
||||
|
||||
//重置全选的文字
|
||||
if (cbSelectedAll.isChecked) {
|
||||
cbSelectedAll.text = context.getString(
|
||||
R.string.select_cancel_count,
|
||||
selectCount,
|
||||
allCount
|
||||
)
|
||||
} else {
|
||||
cbSelectedAll.text = context.getString(
|
||||
R.string.select_all_count,
|
||||
selectCount,
|
||||
allCount
|
||||
)
|
||||
}
|
||||
setMenuClickable(selectCount > 0)
|
||||
}
|
||||
|
||||
private fun setMenuClickable(isClickable: Boolean) = binding.run {
|
||||
btnRevertSelection.isEnabled = isClickable
|
||||
btnRevertSelection.isClickable = isClickable
|
||||
btnSelectActionMain.isEnabled = isClickable
|
||||
btnSelectActionMain.isClickable = isClickable
|
||||
// if (isClickable) {
|
||||
// ivMenuMore.setColorFilter(primaryTextColor, PorterDuff.Mode.SRC_IN)
|
||||
// } else {
|
||||
// ivMenuMore.setColorFilter(disabledColor, PorterDuff.Mode.SRC_IN)
|
||||
// }
|
||||
ivMenuMore.isEnabled = isClickable
|
||||
ivMenuMore.isClickable = isClickable
|
||||
}
|
||||
|
||||
interface CallBack {
|
||||
|
||||
fun selectAll(selectAll: Boolean)
|
||||
|
||||
fun revertSelection()
|
||||
|
||||
fun onClickSelectBarMainAction() {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package io.legato.kazusa.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.RelativeLayout
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.utils.getCompatColor
|
||||
|
||||
/**
|
||||
* ShadowLayout.java
|
||||
*
|
||||
* Created by lijiankun on 17/8/11.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class ShadowLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : RelativeLayout(context, attrs) {
|
||||
private val mPaint =
|
||||
Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val mRectF = RectF()
|
||||
|
||||
/**
|
||||
* 阴影的颜色
|
||||
*/
|
||||
private var mShadowColor = Color.TRANSPARENT
|
||||
|
||||
/**
|
||||
* 阴影的大小范围
|
||||
*/
|
||||
private var mShadowRadius = 0f
|
||||
|
||||
/**
|
||||
* 阴影 x 轴的偏移量
|
||||
*/
|
||||
private var mShadowDx = 0f
|
||||
|
||||
/**
|
||||
* 阴影 y 轴的偏移量
|
||||
*/
|
||||
private var mShadowDy = 0f
|
||||
|
||||
/**
|
||||
* 阴影显示的边界
|
||||
*/
|
||||
private var mShadowSide = ALL
|
||||
|
||||
/**
|
||||
* 阴影的形状,圆形/矩形
|
||||
*/
|
||||
private var mShadowShape = SHAPE_RECTANGLE
|
||||
|
||||
|
||||
init {
|
||||
setLayerType(View.LAYER_TYPE_SOFTWARE, null) // 关闭硬件加速
|
||||
setWillNotDraw(false) // 调用此方法后,才会执行 onDraw(Canvas) 方法
|
||||
val typedArray =
|
||||
context.obtainStyledAttributes(attrs, R.styleable.ShadowLayout)
|
||||
mShadowColor = typedArray.getColor(
|
||||
R.styleable.ShadowLayout_shadowColor,
|
||||
context.getCompatColor(android.R.color.black)
|
||||
)
|
||||
mShadowRadius =
|
||||
typedArray.getDimension(R.styleable.ShadowLayout_shadowRadius, dip2px(0f))
|
||||
mShadowDx = typedArray.getDimension(R.styleable.ShadowLayout_shadowDx, dip2px(0f))
|
||||
mShadowDy = typedArray.getDimension(R.styleable.ShadowLayout_shadowDy, dip2px(0f))
|
||||
mShadowSide =
|
||||
typedArray.getInt(R.styleable.ShadowLayout_shadowSide, ALL)
|
||||
mShadowShape = typedArray.getInt(
|
||||
R.styleable.ShadowLayout_shadowShape,
|
||||
SHAPE_RECTANGLE
|
||||
)
|
||||
typedArray.recycle()
|
||||
|
||||
setUpShadowPaint()
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
val effect = mShadowRadius + dip2px(5f)
|
||||
var rectLeft = 0f
|
||||
var rectTop = 0f
|
||||
var rectRight = this.measuredWidth.toFloat()
|
||||
var rectBottom = this.measuredHeight.toFloat()
|
||||
var paddingLeft = 0
|
||||
var paddingTop = 0
|
||||
var paddingRight = 0
|
||||
var paddingBottom = 0
|
||||
this.width
|
||||
if (mShadowSide and LEFT == LEFT) {
|
||||
rectLeft = effect
|
||||
paddingLeft = effect.toInt()
|
||||
}
|
||||
if (mShadowSide and TOP == TOP) {
|
||||
rectTop = effect
|
||||
paddingTop = effect.toInt()
|
||||
}
|
||||
if (mShadowSide and RIGHT == RIGHT) {
|
||||
rectRight = this.measuredWidth - effect
|
||||
paddingRight = effect.toInt()
|
||||
}
|
||||
if (mShadowSide and BOTTOM == BOTTOM) {
|
||||
rectBottom = this.measuredHeight - effect
|
||||
paddingBottom = effect.toInt()
|
||||
}
|
||||
if (mShadowDy != 0.0f) {
|
||||
rectBottom -= mShadowDy
|
||||
paddingBottom += mShadowDy.toInt()
|
||||
}
|
||||
if (mShadowDx != 0.0f) {
|
||||
rectRight -= mShadowDx
|
||||
paddingRight += mShadowDx.toInt()
|
||||
}
|
||||
mRectF.left = rectLeft
|
||||
mRectF.top = rectTop
|
||||
mRectF.right = rectRight
|
||||
mRectF.bottom = rectBottom
|
||||
setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom)
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正绘制阴影的方法
|
||||
*/
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
setUpShadowPaint()
|
||||
if (mShadowShape == SHAPE_RECTANGLE) {
|
||||
canvas.drawRect(mRectF, mPaint)
|
||||
} else if (mShadowShape == SHAPE_OVAL) {
|
||||
canvas.drawCircle(
|
||||
mRectF.centerX(),
|
||||
mRectF.centerY(),
|
||||
mRectF.width().coerceAtMost(mRectF.height()) / 2,
|
||||
mPaint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setShadowColor(shadowColor: Int) {
|
||||
mShadowColor = shadowColor
|
||||
requestLayout()
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
fun setShadowRadius(shadowRadius: Float) {
|
||||
mShadowRadius = shadowRadius
|
||||
requestLayout()
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
private fun setUpShadowPaint() {
|
||||
mPaint.reset()
|
||||
mPaint.isAntiAlias = true
|
||||
mPaint.color = Color.TRANSPARENT
|
||||
mPaint.setShadowLayer(mShadowRadius, mShadowDx, mShadowDy, mShadowColor)
|
||||
}
|
||||
|
||||
/**
|
||||
* dip2px dp 值转 px 值
|
||||
*
|
||||
* @param dpValue dp 值
|
||||
* @return px 值
|
||||
*/
|
||||
private fun dip2px(dpValue: Float): Float {
|
||||
val dm = context.resources.displayMetrics
|
||||
val scale = dm.density
|
||||
return dpValue * scale + 0.5f
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ALL = 0x1111
|
||||
const val LEFT = 0x0001
|
||||
const val TOP = 0x0010
|
||||
const val RIGHT = 0x0100
|
||||
const val BOTTOM = 0x1000
|
||||
const val SHAPE_RECTANGLE = 0x0001
|
||||
const val SHAPE_OVAL = 0x0010
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package io.legato.kazusa.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.util.AttributeSet
|
||||
import android.view.Menu
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
//import io.legado.app.lib.theme.primaryColor
|
||||
import io.legato.kazusa.utils.activity
|
||||
import io.legato.kazusa.utils.setOnApplyWindowInsetsListenerCompat
|
||||
import splitties.views.bottomPadding
|
||||
import splitties.views.topPadding
|
||||
|
||||
@Suppress("unused", "MemberVisibilityCanBePrivate")
|
||||
class TitleBar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppBarLayout(context, attrs) {
|
||||
|
||||
val toolbar: Toolbar
|
||||
val menu: Menu
|
||||
get() = toolbar.menu
|
||||
|
||||
var title: CharSequence?
|
||||
get() = toolbar.title
|
||||
set(title) {
|
||||
if (toolbar.title != title) {
|
||||
toolbar.title = title
|
||||
}
|
||||
}
|
||||
|
||||
var subtitle: CharSequence?
|
||||
get() = toolbar.subtitle
|
||||
set(subtitle) {
|
||||
if (toolbar.subtitle != subtitle) {
|
||||
toolbar.subtitle = subtitle
|
||||
}
|
||||
}
|
||||
|
||||
private val displayHomeAsUp: Boolean
|
||||
private val navigationIconTint: ColorStateList?
|
||||
private val navigationIconTintMode: Int
|
||||
private val fitStatusBar: Boolean
|
||||
private val fitNavigationBar: Boolean
|
||||
private val attachToActivity: Boolean
|
||||
|
||||
init {
|
||||
val a = context.obtainStyledAttributes(
|
||||
attrs, R.styleable.TitleBar,
|
||||
R.attr.titleBarStyle, 0
|
||||
)
|
||||
navigationIconTint = a.getColorStateList(R.styleable.TitleBar_navigationIconTint)
|
||||
navigationIconTintMode = a.getInt(R.styleable.TitleBar_navigationIconTintMode, 9)
|
||||
attachToActivity = a.getBoolean(R.styleable.TitleBar_attachToActivity, true)
|
||||
displayHomeAsUp = a.getBoolean(R.styleable.TitleBar_displayHomeAsUp, true)
|
||||
fitStatusBar = a.getBoolean(R.styleable.TitleBar_fitStatusBar, true)
|
||||
fitNavigationBar = a.getBoolean(R.styleable.TitleBar_fitNavigationBar, false)
|
||||
|
||||
val navigationIcon = a.getDrawable(R.styleable.TitleBar_navigationIcon)
|
||||
val navigationContentDescription =
|
||||
a.getText(R.styleable.TitleBar_navigationContentDescription)
|
||||
val titleText = a.getString(R.styleable.TitleBar_title)
|
||||
val subtitleText = a.getString(R.styleable.TitleBar_subtitle)
|
||||
|
||||
when (a.getInt(R.styleable.TitleBar_themeMode, 0)) {
|
||||
1 -> inflate(context, R.layout.view_title_bar_dark, this)
|
||||
else -> inflate(context, R.layout.view_title_bar, this)
|
||||
}
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
|
||||
toolbar.apply {
|
||||
navigationIcon?.let {
|
||||
this.navigationIcon = it
|
||||
this.navigationContentDescription = navigationContentDescription
|
||||
}
|
||||
|
||||
if (a.hasValue(R.styleable.TitleBar_titleTextAppearance)) {
|
||||
this.setTitleTextAppearance(
|
||||
context,
|
||||
a.getResourceId(R.styleable.TitleBar_titleTextAppearance, 0)
|
||||
)
|
||||
}
|
||||
|
||||
if (a.hasValue(R.styleable.TitleBar_titleTextColor)) {
|
||||
this.setTitleTextColor(a.getColor(R.styleable.TitleBar_titleTextColor, -0x1))
|
||||
}
|
||||
|
||||
if (a.hasValue(R.styleable.TitleBar_subtitleTextAppearance)) {
|
||||
this.setSubtitleTextAppearance(
|
||||
context,
|
||||
a.getResourceId(R.styleable.TitleBar_subtitleTextAppearance, 0)
|
||||
)
|
||||
}
|
||||
|
||||
if (a.hasValue(R.styleable.TitleBar_subtitleTextColor)) {
|
||||
this.setSubtitleTextColor(a.getColor(R.styleable.TitleBar_subtitleTextColor, -0x1))
|
||||
}
|
||||
|
||||
|
||||
if (a.hasValue(R.styleable.TitleBar_contentInsetLeft)
|
||||
|| a.hasValue(R.styleable.TitleBar_contentInsetRight)
|
||||
) {
|
||||
this.setContentInsetsAbsolute(
|
||||
a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetLeft, 0),
|
||||
a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetRight, 0)
|
||||
)
|
||||
}
|
||||
|
||||
if (a.hasValue(R.styleable.TitleBar_contentInsetStart)
|
||||
|| a.hasValue(R.styleable.TitleBar_contentInsetEnd)
|
||||
) {
|
||||
this.setContentInsetsRelative(
|
||||
a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetStart, 0),
|
||||
a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetEnd, 0)
|
||||
)
|
||||
}
|
||||
|
||||
if (a.hasValue(R.styleable.TitleBar_contentInsetStartWithNavigation)) {
|
||||
this.contentInsetStartWithNavigation = a.getDimensionPixelOffset(
|
||||
R.styleable.TitleBar_contentInsetStartWithNavigation, 0
|
||||
)
|
||||
}
|
||||
|
||||
if (a.hasValue(R.styleable.TitleBar_contentInsetEndWithActions)) {
|
||||
this.contentInsetEndWithActions = a.getDimensionPixelOffset(
|
||||
R.styleable.TitleBar_contentInsetEndWithActions, 0
|
||||
)
|
||||
}
|
||||
|
||||
if (!titleText.isNullOrBlank()) {
|
||||
this.title = titleText
|
||||
}
|
||||
|
||||
if (!subtitleText.isNullOrBlank()) {
|
||||
this.subtitle = subtitleText
|
||||
}
|
||||
|
||||
if (a.hasValue(R.styleable.TitleBar_contentLayout)) {
|
||||
inflate(context, a.getResourceId(R.styleable.TitleBar_contentLayout, 0), this)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInEditMode) {
|
||||
// if (fitStatusBar) {
|
||||
// setPadding(paddingLeft, context.statusBarHeight, paddingRight, paddingBottom)
|
||||
// }
|
||||
//
|
||||
// if (fitNavigationBar) {
|
||||
// setPadding(paddingLeft, paddingTop, paddingRight, context.navigationBarHeight)
|
||||
// }
|
||||
|
||||
if (fitStatusBar || fitNavigationBar) {
|
||||
setOnApplyWindowInsetsListenerCompat { _, windowInsets ->
|
||||
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
if (fitStatusBar) {
|
||||
topPadding = insets.top
|
||||
}
|
||||
if (fitNavigationBar) {
|
||||
bottomPadding = insets.bottom
|
||||
}
|
||||
windowInsets
|
||||
}
|
||||
}
|
||||
|
||||
if (AppConfig.isEInkMode) {
|
||||
setBackgroundResource(R.drawable.bg_eink_border_bottom)
|
||||
} else {
|
||||
//setBackgroundColor(context.primaryColor)
|
||||
}
|
||||
|
||||
stateListAnimator = null
|
||||
//elevation = context.elevation
|
||||
}
|
||||
a.recycle()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
attachToActivity()
|
||||
}
|
||||
|
||||
fun setNavigationOnClickListener(clickListener: ((View) -> Unit)) {
|
||||
toolbar.setNavigationOnClickListener(clickListener)
|
||||
}
|
||||
|
||||
fun setTitle(titleId: Int) {
|
||||
toolbar.setTitle(titleId)
|
||||
}
|
||||
|
||||
fun setSubTitle(subtitleId: Int) {
|
||||
toolbar.setSubtitle(subtitleId)
|
||||
}
|
||||
|
||||
// fun setTitleTextColor(@ColorInt color: Int) {
|
||||
// toolbar.setTitleTextColor(color)
|
||||
// }
|
||||
//
|
||||
// fun setTitleTextAppearance(@StyleRes resId: Int) {
|
||||
// toolbar.setTitleTextAppearance(context, resId)
|
||||
// }
|
||||
//
|
||||
// fun setSubTitleTextColor(@ColorInt color: Int) {
|
||||
// toolbar.setSubtitleTextColor(color)
|
||||
// }
|
||||
//
|
||||
// fun setSubTitleTextAppearance(@StyleRes resId: Int) {
|
||||
// toolbar.setSubtitleTextAppearance(context, resId)
|
||||
// }
|
||||
//
|
||||
// fun setTextColor(@ColorInt color: Int) {
|
||||
// setTitleTextColor(color)
|
||||
// setSubTitleTextColor(color)
|
||||
// }
|
||||
//
|
||||
// fun setColorFilter(@ColorInt color: Int) {
|
||||
// val colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP)
|
||||
// toolbar.children.firstOrNull { it is ImageView }?.background?.colorFilter = colorFilter
|
||||
// toolbar.navigationIcon?.colorFilter = colorFilter
|
||||
// toolbar.overflowIcon?.colorFilter = colorFilter
|
||||
// toolbar.menu.children.forEach {
|
||||
// it.icon?.colorFilter = colorFilter
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun setBackgroundColor(color: Int) {
|
||||
// if (color.alpha < 255) {
|
||||
// //这里不能改为0f,改为0f在横屏模式下文字和图标颜色会变
|
||||
// elevation = 0.1f
|
||||
// }
|
||||
// super.setBackgroundColor(color)
|
||||
// }
|
||||
//
|
||||
// override fun setBackground(background: Drawable?) {
|
||||
// if (background is ColorDrawable) {
|
||||
// if (background.alpha < 255) {
|
||||
// //这里不能改为0f,改为0f在横屏模式下文字和图标颜色会变
|
||||
// elevation = 0.1f
|
||||
// }
|
||||
// }
|
||||
// super.setBackground(background)
|
||||
// }
|
||||
|
||||
fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, fullScreen: Boolean) {
|
||||
// if (fitStatusBar) {
|
||||
// val topPadding = if (!isInMultiWindowMode && fullScreen) context.statusBarHeight else 0
|
||||
// setPadding(paddingLeft, topPadding, paddingRight, paddingBottom)
|
||||
// }
|
||||
}
|
||||
|
||||
private fun attachToActivity() {
|
||||
if (attachToActivity) {
|
||||
activity?.let {
|
||||
it.setSupportActionBar(toolbar)
|
||||
it.supportActionBar?.setDisplayHomeAsUpEnabled(displayHomeAsUp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package io.legato.kazusa.ui.widget.anima
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.os.Looper
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
|
||||
import io.legato.kazusa.R
|
||||
|
||||
@Suppress("unused", "MemberVisibilityCanBePrivate")
|
||||
class RefreshProgressBar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs) {
|
||||
private var a = 1
|
||||
private var durProgress = 0
|
||||
private var secondDurProgress = 0
|
||||
var maxProgress = 100
|
||||
var secondMaxProgress = 100
|
||||
var bgColor = 0x00000000
|
||||
var secondColor = -0x3e3e3f
|
||||
var fontColor = -0xc9c9ca
|
||||
var speed = 2
|
||||
var secondFinalProgress = 0
|
||||
private set
|
||||
private var paint: Paint = Paint()
|
||||
private val bgRect = Rect()
|
||||
private val secondRect = Rect()
|
||||
private val fontRectF = RectF()
|
||||
|
||||
var isAutoLoading: Boolean = false
|
||||
set(loading) {
|
||||
field = loading
|
||||
if (!loading) {
|
||||
secondDurProgress = 0
|
||||
secondFinalProgress = 0
|
||||
}
|
||||
maxProgress = 0
|
||||
|
||||
invalidate()
|
||||
}
|
||||
|
||||
init {
|
||||
paint.style = Paint.Style.FILL
|
||||
|
||||
val a = context.obtainStyledAttributes(attrs, R.styleable.RefreshProgressBar)
|
||||
speed = a.getDimensionPixelSize(R.styleable.RefreshProgressBar_speed, speed)
|
||||
maxProgress = a.getInt(R.styleable.RefreshProgressBar_max_progress, maxProgress)
|
||||
durProgress = a.getInt(R.styleable.RefreshProgressBar_dur_progress, durProgress)
|
||||
secondDurProgress = a.getDimensionPixelSize(
|
||||
R.styleable.RefreshProgressBar_second_dur_progress,
|
||||
secondDurProgress
|
||||
)
|
||||
secondFinalProgress = secondDurProgress
|
||||
secondMaxProgress = a.getDimensionPixelSize(
|
||||
R.styleable.RefreshProgressBar_second_max_progress,
|
||||
secondMaxProgress
|
||||
)
|
||||
bgColor = a.getColor(R.styleable.RefreshProgressBar_bg_color, bgColor)
|
||||
secondColor = a.getColor(R.styleable.RefreshProgressBar_second_color, secondColor)
|
||||
fontColor = a.getColor(R.styleable.RefreshProgressBar_font_color, fontColor)
|
||||
a.recycle()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
paint.color = bgColor
|
||||
bgRect.set(0, 0, measuredWidth, measuredHeight)
|
||||
canvas.drawRect(bgRect, paint)
|
||||
|
||||
if (secondDurProgress > 0 && secondMaxProgress > 0) {
|
||||
var secondDur = secondDurProgress
|
||||
if (secondDur > secondMaxProgress) {
|
||||
secondDur = secondMaxProgress
|
||||
}
|
||||
paint.color = secondColor
|
||||
val tempW =
|
||||
(measuredWidth.toFloat() * 1.0f * (secondDur * 1.0f / secondMaxProgress)).toInt()
|
||||
secondRect.set(
|
||||
measuredWidth / 2 - tempW / 2,
|
||||
0,
|
||||
measuredWidth / 2 + tempW / 2,
|
||||
measuredHeight
|
||||
)
|
||||
canvas.drawRect(secondRect, paint)
|
||||
}
|
||||
|
||||
if (durProgress > 0 && maxProgress > 0) {
|
||||
paint.color = fontColor
|
||||
fontRectF.set(
|
||||
0f,
|
||||
0f,
|
||||
measuredWidth.toFloat() * 1.0f * (durProgress * 1.0f / maxProgress),
|
||||
measuredHeight.toFloat()
|
||||
)
|
||||
canvas.drawRect(fontRectF, paint)
|
||||
}
|
||||
|
||||
if (this.isAutoLoading) {
|
||||
if (secondDurProgress >= secondMaxProgress) {
|
||||
a = -1
|
||||
} else if (secondDurProgress <= 0) {
|
||||
a = 1
|
||||
}
|
||||
secondDurProgress += a * speed
|
||||
if (secondDurProgress < 0)
|
||||
secondDurProgress = 0
|
||||
else if (secondDurProgress > secondMaxProgress)
|
||||
secondDurProgress = secondMaxProgress
|
||||
secondFinalProgress = secondDurProgress
|
||||
invalidate()
|
||||
} else {
|
||||
if (secondDurProgress != secondFinalProgress) {
|
||||
if (secondDurProgress > secondFinalProgress) {
|
||||
secondDurProgress -= speed
|
||||
if (secondDurProgress < secondFinalProgress) {
|
||||
secondDurProgress = secondFinalProgress
|
||||
}
|
||||
} else {
|
||||
secondDurProgress += speed
|
||||
if (secondDurProgress > secondFinalProgress) {
|
||||
secondDurProgress = secondFinalProgress
|
||||
}
|
||||
}
|
||||
this.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getDurProgress(): Int {
|
||||
return durProgress
|
||||
}
|
||||
|
||||
fun setDurProgress(durProgress: Int) {
|
||||
var durProgress1 = durProgress
|
||||
if (durProgress1 < 0) {
|
||||
durProgress1 = 0
|
||||
}
|
||||
if (durProgress1 > maxProgress) {
|
||||
durProgress1 = maxProgress
|
||||
}
|
||||
this.durProgress = durProgress1
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
this.invalidate()
|
||||
} else {
|
||||
this.postInvalidate()
|
||||
}
|
||||
}
|
||||
|
||||
fun getSecondDurProgress(): Int {
|
||||
return secondDurProgress
|
||||
}
|
||||
|
||||
fun setSecondDurProgress(secondDur: Int) {
|
||||
this.secondDurProgress = secondDur
|
||||
this.secondFinalProgress = secondDurProgress
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
this.invalidate()
|
||||
} else {
|
||||
this.postInvalidate()
|
||||
}
|
||||
}
|
||||
|
||||
fun setSecondDurProgressWithAnim(secondDur: Int) {
|
||||
var secondDur1 = secondDur
|
||||
if (secondDur1 < 0) {
|
||||
secondDur1 = 0
|
||||
}
|
||||
if (secondDur1 > secondMaxProgress) {
|
||||
secondDur1 = secondMaxProgress
|
||||
}
|
||||
this.secondFinalProgress = secondDur1
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
this.invalidate()
|
||||
} else {
|
||||
this.postInvalidate()
|
||||
}
|
||||
}
|
||||
|
||||
fun clean() {
|
||||
durProgress = 0
|
||||
secondDurProgress = 0
|
||||
secondFinalProgress = 0
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
this.invalidate()
|
||||
} else {
|
||||
this.postInvalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package io.legato.kazusa.ui.widget.anima
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import io.legato.kazusa.R
|
||||
//import io.legado.app.lib.theme.accentColor
|
||||
import io.legato.kazusa.utils.dpToPx
|
||||
|
||||
/**
|
||||
* RotateLoading
|
||||
* Created by Victor on 2015/4/28.
|
||||
*/
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
class RotateLoading @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs) {
|
||||
|
||||
private var mPaint: Paint
|
||||
|
||||
private var loadingRectF: RectF? = null
|
||||
private var shadowRectF: RectF? = null
|
||||
|
||||
private var topDegree = 10
|
||||
private var bottomDegree = 190
|
||||
|
||||
private var arc: Float = 0.toFloat()
|
||||
|
||||
private var thisWidth: Int = 0
|
||||
|
||||
private var changeBigger = true
|
||||
|
||||
private var shadowPosition: Int = 0
|
||||
|
||||
private var hideMode = GONE
|
||||
|
||||
var isStarted = false
|
||||
private set
|
||||
|
||||
var loadingColor: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private var speedOfDegree: Int = 0
|
||||
|
||||
private var speedOfArc: Float = 0.toFloat()
|
||||
|
||||
private val shown = Runnable { this.startInternal() }
|
||||
|
||||
private val hidden = Runnable { this.stopInternal() }
|
||||
|
||||
init {
|
||||
//loadingColor = context.accentColor
|
||||
thisWidth = DEFAULT_WIDTH.dpToPx()
|
||||
shadowPosition = DEFAULT_SHADOW_POSITION.dpToPx()
|
||||
speedOfDegree = DEFAULT_SPEED_OF_DEGREE
|
||||
|
||||
if (null != attrs) {
|
||||
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.RotateLoading)
|
||||
loadingColor =
|
||||
typedArray.getColor(R.styleable.RotateLoading_loading_color, loadingColor)
|
||||
thisWidth = typedArray.getDimensionPixelSize(
|
||||
R.styleable.RotateLoading_loading_width,
|
||||
DEFAULT_WIDTH.dpToPx()
|
||||
)
|
||||
shadowPosition = typedArray.getInt(
|
||||
R.styleable.RotateLoading_shadow_position,
|
||||
DEFAULT_SHADOW_POSITION
|
||||
)
|
||||
speedOfDegree =
|
||||
typedArray.getInt(R.styleable.RotateLoading_loading_speed, DEFAULT_SPEED_OF_DEGREE)
|
||||
typedArray.recycle()
|
||||
}
|
||||
speedOfArc = (speedOfDegree / 4).toFloat()
|
||||
mPaint = Paint()
|
||||
mPaint.color = loadingColor
|
||||
mPaint.isAntiAlias = true
|
||||
mPaint.style = Paint.Style.STROKE
|
||||
mPaint.strokeWidth = thisWidth.toFloat()
|
||||
mPaint.strokeCap = Paint.Cap.ROUND
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
|
||||
arc = 10f
|
||||
|
||||
loadingRectF =
|
||||
RectF(
|
||||
(2 * thisWidth).toFloat(),
|
||||
(2 * thisWidth).toFloat(),
|
||||
(w - 2 * thisWidth).toFloat(),
|
||||
(h - 2 * thisWidth).toFloat()
|
||||
)
|
||||
shadowRectF = RectF(
|
||||
(2 * thisWidth + shadowPosition).toFloat(),
|
||||
(2 * thisWidth + shadowPosition).toFloat(),
|
||||
(w - 2 * thisWidth + shadowPosition).toFloat(),
|
||||
(h - 2 * thisWidth + shadowPosition).toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
if (!isStarted) {
|
||||
return
|
||||
}
|
||||
|
||||
mPaint.color = Color.parseColor("#1a000000")
|
||||
shadowRectF?.let {
|
||||
canvas.drawArc(it, topDegree.toFloat(), arc, false, mPaint)
|
||||
canvas.drawArc(it, bottomDegree.toFloat(), arc, false, mPaint)
|
||||
}
|
||||
|
||||
mPaint.color = loadingColor
|
||||
loadingRectF?.let {
|
||||
canvas.drawArc(it, topDegree.toFloat(), arc, false, mPaint)
|
||||
canvas.drawArc(it, bottomDegree.toFloat(), arc, false, mPaint)
|
||||
}
|
||||
|
||||
topDegree += speedOfDegree
|
||||
bottomDegree += speedOfDegree
|
||||
if (topDegree > 360) {
|
||||
topDegree -= 360
|
||||
}
|
||||
if (bottomDegree > 360) {
|
||||
bottomDegree -= 360
|
||||
}
|
||||
|
||||
if (changeBigger) {
|
||||
if (arc < 160) {
|
||||
arc += speedOfArc
|
||||
invalidate()
|
||||
}
|
||||
} else {
|
||||
if (arc > speedOfDegree) {
|
||||
arc -= 2 * speedOfArc
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
if (arc >= 160 || arc <= 10) {
|
||||
changeBigger = !changeBigger
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
if (visibility == VISIBLE) {
|
||||
startInternal()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
isStarted = false
|
||||
animate().cancel()
|
||||
removeCallbacks(shown)
|
||||
removeCallbacks(hidden)
|
||||
}
|
||||
|
||||
fun visible() {
|
||||
removeCallbacks(shown)
|
||||
removeCallbacks(hidden)
|
||||
post(shown)
|
||||
}
|
||||
|
||||
fun inVisible() {
|
||||
hideMode = INVISIBLE
|
||||
removeCallbacks(shown)
|
||||
removeCallbacks(hidden)
|
||||
stopInternal()
|
||||
}
|
||||
|
||||
fun gone() {
|
||||
hideMode = GONE
|
||||
removeCallbacks(shown)
|
||||
removeCallbacks(hidden)
|
||||
stopInternal()
|
||||
}
|
||||
|
||||
private fun startInternal() {
|
||||
startAnimator()
|
||||
isStarted = true
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun stopInternal() {
|
||||
stopAnimator()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun startAnimator() {
|
||||
animate().cancel()
|
||||
animate().scaleX(1.0f)
|
||||
.scaleY(1.0f)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationStart(animation: Animator) {
|
||||
visibility = VISIBLE
|
||||
}
|
||||
})
|
||||
.start()
|
||||
}
|
||||
|
||||
private fun stopAnimator() {
|
||||
animate().cancel()
|
||||
isStarted = false
|
||||
this.visibility = hideMode
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_WIDTH = 6
|
||||
private const val DEFAULT_SHADOW_POSITION = 2
|
||||
private const val DEFAULT_SPEED_OF_DEGREE = 10
|
||||
}
|
||||
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (C) 2015 tyrantgit
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.legato.kazusa.ui.widget.anima.explosion_field
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.*
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import java.util.*
|
||||
import kotlin.math.pow
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
class ExplosionAnimator(private val mContainer: View, bitmap: Bitmap, bound: Rect) :
|
||||
ValueAnimator() {
|
||||
|
||||
private val mPaint: Paint = Paint()
|
||||
private val mParticles: Array<Particle?>
|
||||
private val mBound: Rect = Rect(bound)
|
||||
|
||||
init {
|
||||
val partLen = 15
|
||||
mParticles = arrayOfNulls(partLen * partLen)
|
||||
val random = Random(System.currentTimeMillis())
|
||||
val w = bitmap.width / (partLen + 2)
|
||||
val h = bitmap.height / (partLen + 2)
|
||||
for (i in 0 until partLen) {
|
||||
for (j in 0 until partLen) {
|
||||
mParticles[i * partLen + j] =
|
||||
generateParticle(bitmap.getPixel((j + 1) * w, (i + 1) * h), random)
|
||||
}
|
||||
}
|
||||
setFloatValues(0f, END_VALUE)
|
||||
interpolator = DEFAULT_INTERPOLATOR
|
||||
duration = DEFAULT_DURATION
|
||||
}
|
||||
|
||||
private fun generateParticle(color: Int, random: Random): Particle {
|
||||
val particle = Particle()
|
||||
particle.color = color
|
||||
particle.radius = V
|
||||
if (random.nextFloat() < 0.2f) {
|
||||
particle.baseRadius = V + (X - V) * random.nextFloat()
|
||||
} else {
|
||||
particle.baseRadius = W + (V - W) * random.nextFloat()
|
||||
}
|
||||
val nextFloat = random.nextFloat()
|
||||
particle.top = mBound.height() * (0.18f * random.nextFloat() + 0.2f)
|
||||
particle.top =
|
||||
if (nextFloat < 0.2f) particle.top else particle.top + particle.top * 0.2f * random.nextFloat()
|
||||
particle.bottom = mBound.height() * (random.nextFloat() - 0.5f) * 1.8f
|
||||
var f =
|
||||
if (nextFloat < 0.2f) particle.bottom else if (nextFloat < 0.8f) particle.bottom * 0.6f else particle.bottom * 0.3f
|
||||
particle.bottom = f
|
||||
particle.mag = 4.0f * particle.top / particle.bottom
|
||||
particle.neg = -particle.mag / particle.bottom
|
||||
f = mBound.centerX() + Y * (random.nextFloat() - 0.5f)
|
||||
particle.baseCx = f
|
||||
particle.cx = f
|
||||
f = mBound.centerY() + Y * (random.nextFloat() - 0.5f)
|
||||
particle.baseCy = f
|
||||
particle.cy = f
|
||||
particle.life = END_VALUE / 10 * random.nextFloat()
|
||||
particle.overflow = 0.4f * random.nextFloat()
|
||||
particle.alpha = 1f
|
||||
return particle
|
||||
}
|
||||
|
||||
fun draw(canvas: Canvas): Boolean {
|
||||
if (!isStarted) {
|
||||
return false
|
||||
}
|
||||
for (particle in mParticles) {
|
||||
particle?.let {
|
||||
particle.advance(animatedValue as Float)
|
||||
if (particle.alpha > 0f) {
|
||||
mPaint.color = particle.color
|
||||
mPaint.alpha = (Color.alpha(particle.color) * particle.alpha).toInt()
|
||||
canvas.drawCircle(particle.cx, particle.cy, particle.radius, mPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
mContainer.invalidate()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
super.start()
|
||||
mContainer.invalidate()
|
||||
}
|
||||
|
||||
private inner class Particle {
|
||||
var alpha: Float = 0.toFloat()
|
||||
var color: Int = 0
|
||||
var cx: Float = 0.toFloat()
|
||||
var cy: Float = 0.toFloat()
|
||||
var radius: Float = 0.toFloat()
|
||||
var baseCx: Float = 0.toFloat()
|
||||
var baseCy: Float = 0.toFloat()
|
||||
var baseRadius: Float = 0.toFloat()
|
||||
var top: Float = 0.toFloat()
|
||||
var bottom: Float = 0.toFloat()
|
||||
var mag: Float = 0.toFloat()
|
||||
var neg: Float = 0.toFloat()
|
||||
var life: Float = 0.toFloat()
|
||||
var overflow: Float = 0.toFloat()
|
||||
|
||||
|
||||
fun advance(factor: Float) {
|
||||
var f = 0f
|
||||
var normalization = factor / END_VALUE
|
||||
if (normalization < life || normalization > 1f - overflow) {
|
||||
alpha = 0f
|
||||
return
|
||||
}
|
||||
normalization = (normalization - life) / (1f - life - overflow)
|
||||
val f2 = normalization * END_VALUE
|
||||
if (normalization >= 0.7f) {
|
||||
f = (normalization - 0.7f) / 0.3f
|
||||
}
|
||||
alpha = 1f - f
|
||||
f = bottom * f2
|
||||
cx = baseCx + f
|
||||
cy = (baseCy - this.neg * f.toDouble().pow(2.0)).toFloat() - f * mag
|
||||
radius = V + (baseRadius - V) * f2
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
internal var DEFAULT_DURATION: Long = 0x400
|
||||
private val DEFAULT_INTERPOLATOR = AccelerateInterpolator(0.6f)
|
||||
private const val END_VALUE = 1.4f
|
||||
private val X = Utils.dp2Px(5).toFloat()
|
||||
private val Y = Utils.dp2Px(20).toFloat()
|
||||
private val V = Utils.dp2Px(2).toFloat()
|
||||
private val W = Utils.dp2Px(1).toFloat()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package io.legato.kazusa.ui.widget.anima.explosion_field
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.Window
|
||||
|
||||
object ExplosionField {
|
||||
|
||||
fun attach2Window(activity: Activity): ExplosionView {
|
||||
val rootView = activity.findViewById<View>(Window.ID_ANDROID_CONTENT) as ViewGroup
|
||||
val explosionField = ExplosionView(activity)
|
||||
rootView.addView(
|
||||
explosionField, ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
return explosionField
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright (C) 2015 tyrantgit
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.legato.kazusa.ui.widget.anima.explosion_field
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.media.MediaPlayer
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import io.legato.kazusa.utils.DebugLog
|
||||
|
||||
import java.util.*
|
||||
|
||||
|
||||
@Suppress("unused")
|
||||
class ExplosionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
|
||||
View(context, attrs) {
|
||||
|
||||
private var customDuration = ExplosionAnimator.DEFAULT_DURATION
|
||||
private var idPlayAnimationEffect = 0
|
||||
private var mZAnimatorListener: OnAnimatorListener? = null
|
||||
private var mOnClickListener: OnClickListener? = null
|
||||
|
||||
private val mExplosions = ArrayList<ExplosionAnimator>()
|
||||
private val mExpandInset = IntArray(2)
|
||||
|
||||
init {
|
||||
Arrays.fill(mExpandInset, Utils.dp2Px(32))
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
for (explosion in mExplosions) {
|
||||
explosion.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
fun playSoundAnimationEffect(id: Int) {
|
||||
this.idPlayAnimationEffect = id
|
||||
}
|
||||
|
||||
fun setCustomDuration(customDuration: Long) {
|
||||
this.customDuration = customDuration
|
||||
}
|
||||
|
||||
fun addActionEvent(iEvents: OnAnimatorListener) {
|
||||
this.mZAnimatorListener = iEvents
|
||||
}
|
||||
|
||||
|
||||
fun expandExplosionBound(dx: Int, dy: Int) {
|
||||
mExpandInset[0] = dx
|
||||
mExpandInset[1] = dy
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun explode(bitmap: Bitmap?, bound: Rect, startDelay: Long, view: View? = null) {
|
||||
val currentDuration = customDuration
|
||||
val explosion = ExplosionAnimator(this, bitmap!!, bound)
|
||||
explosion.addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
mExplosions.remove(animation)
|
||||
view?.let {
|
||||
view.scaleX = 1f
|
||||
view.scaleY = 1f
|
||||
view.alpha = 1f
|
||||
//view.setOnClickListener(mOnClickListener)//set event
|
||||
}
|
||||
}
|
||||
})
|
||||
explosion.startDelay = startDelay
|
||||
explosion.duration = currentDuration
|
||||
mExplosions.add(explosion)
|
||||
explosion.start()
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun explode(view: View, restartState: Boolean? = false) {
|
||||
|
||||
val r = Rect()
|
||||
view.getGlobalVisibleRect(r)
|
||||
val location = IntArray(2)
|
||||
getLocationOnScreen(location)
|
||||
r.offset(-location[0], -location[1])
|
||||
r.inset(-mExpandInset[0], -mExpandInset[1])
|
||||
val startDelay = 100
|
||||
val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(150)
|
||||
animator.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener {
|
||||
|
||||
var random = Random()
|
||||
|
||||
override fun onAnimationUpdate(animation: ValueAnimator) {
|
||||
view.translationX = (random.nextFloat() - 0.5f) * view.width.toFloat() * 0.05f
|
||||
view.translationY = (random.nextFloat() - 0.5f) * view.height.toFloat() * 0.05f
|
||||
}
|
||||
})
|
||||
|
||||
animator.addListener(object : Animator.AnimatorListener {
|
||||
override fun onAnimationStart(animator: Animator) {
|
||||
if (idPlayAnimationEffect != 0)
|
||||
MediaPlayer.create(context, idPlayAnimationEffect).start()
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animator: Animator) {
|
||||
if (mZAnimatorListener != null) {
|
||||
mZAnimatorListener!!.onAnimationEnd(animator, this@ExplosionView)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAnimationCancel(animator: Animator) {
|
||||
DebugLog.i(javaClass.name, "CANCEL")
|
||||
}
|
||||
|
||||
override fun onAnimationRepeat(animator: Animator) {
|
||||
DebugLog.i(javaClass.name, "REPEAT")
|
||||
}
|
||||
})
|
||||
|
||||
animator.start()
|
||||
view.animate().setDuration(150).setStartDelay(startDelay.toLong()).scaleX(0f).scaleY(0f)
|
||||
.alpha(0f).start()
|
||||
if (restartState!!)
|
||||
explode(Utils.createBitmapFromView(view), r, startDelay.toLong(), view)
|
||||
else
|
||||
explode(Utils.createBitmapFromView(view), r, startDelay.toLong())
|
||||
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
mExplosions.clear()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun setOnClickListener(mOnClickListener: OnClickListener?) {
|
||||
this.mOnClickListener = mOnClickListener
|
||||
}
|
||||
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package io.legato.kazusa.ui.widget.anima.explosion_field
|
||||
|
||||
import android.animation.Animator
|
||||
import android.view.View
|
||||
|
||||
interface OnAnimatorListener {
|
||||
fun onAnimationEnd(animator: Animator, view: View)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (C) 2015 tyrantgit
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.legato.kazusa.ui.widget.anima.explosion_field
|
||||
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object Utils {
|
||||
|
||||
private val DENSITY = Resources.getSystem().displayMetrics.density
|
||||
private val sCanvas = Canvas()
|
||||
|
||||
fun dp2Px(dp: Int): Int {
|
||||
return (dp * DENSITY).roundToInt()
|
||||
}
|
||||
|
||||
fun createBitmapFromView(view: View): Bitmap? {
|
||||
if (view is ImageView) {
|
||||
val drawable = view.drawable
|
||||
if (drawable != null && drawable is BitmapDrawable) {
|
||||
return drawable.bitmap
|
||||
}
|
||||
}
|
||||
view.clearFocus()
|
||||
val bitmap = createBitmapSafely(
|
||||
view.width,
|
||||
view.height, Bitmap.Config.ARGB_8888, 1
|
||||
)
|
||||
if (bitmap != null) {
|
||||
synchronized(sCanvas) {
|
||||
val canvas = sCanvas
|
||||
canvas.setBitmap(bitmap)
|
||||
view.draw(canvas)
|
||||
canvas.setBitmap(null)
|
||||
}
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun createBitmapSafely(
|
||||
width: Int,
|
||||
height: Int,
|
||||
config: Bitmap.Config,
|
||||
retryCount: Int
|
||||
): Bitmap? {
|
||||
try {
|
||||
return Bitmap.createBitmap(width, height, config)
|
||||
} catch (e: OutOfMemoryError) {
|
||||
e.printOnDebug()
|
||||
if (retryCount > 0) {
|
||||
System.gc()
|
||||
return createBitmapSafely(width, height, config, retryCount - 1)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package io.legato.kazusa.ui.widget.button
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import com.google.android.material.button.MaterialButton
|
||||
//import io.legado.app.lib.theme.bottomBackground
|
||||
|
||||
class AccentStrokeButton(context: Context, attrs: AttributeSet) :
|
||||
MaterialButton(context, attrs) {
|
||||
|
||||
// private val isBottomBackground: Boolean
|
||||
|
||||
// init {
|
||||
// val typedArray = context.obtainStyledAttributes(attrs, R.styleable.AccentStrokeTextView)
|
||||
// isBottomBackground =
|
||||
// typedArray.getBoolean(R.styleable.StrokeTextView_isBottomBackground, false)
|
||||
// typedArray.recycle()
|
||||
//
|
||||
// // MaterialButton specific attributes
|
||||
// insetTop = 0
|
||||
// insetBottom = 0
|
||||
// upStyle()
|
||||
// }
|
||||
|
||||
// private fun upStyle() {
|
||||
// val isLight = ColorUtils.isColorLight(context.bottomBackground)
|
||||
// val disableColor = if (isBottomBackground) {
|
||||
// if (isLight) {
|
||||
// context.getCompatColor(R.color.md_light_disabled)
|
||||
// } else {
|
||||
// context.getCompatColor(R.color.md_dark_disabled)
|
||||
// }
|
||||
// } else {
|
||||
// context.getCompatColor(R.color.disabled)
|
||||
// }
|
||||
// val accentColor = if (isInEditMode) {
|
||||
// context.getCompatColor(R.color.accent)
|
||||
// } else {
|
||||
// ThemeStore.accentColor(context)
|
||||
// }
|
||||
//
|
||||
// // Set MaterialButton properties
|
||||
// strokeWidth = 1.dpToPx()
|
||||
// strokeColor = ColorStateList.valueOf(accentColor)
|
||||
//
|
||||
// // For disabled state
|
||||
// val states = arrayOf(
|
||||
// intArrayOf(-android.R.attr.state_enabled), // disabled
|
||||
// intArrayOf(android.R.attr.state_enabled) // enabled
|
||||
// )
|
||||
// val colors = intArrayOf(disableColor, accentColor)
|
||||
//
|
||||
// // Set stroke color state list
|
||||
// strokeColor = ColorStateList(states, colors)
|
||||
//
|
||||
// // Set text color state list
|
||||
// setTextColor(ColorStateList(states, colors))
|
||||
//
|
||||
// // Set ripple color
|
||||
// rippleColor = ColorStateList.valueOf(context.getCompatColor(R.color.transparent30))
|
||||
//
|
||||
// // Remove default background and set transparent background
|
||||
// setBackgroundColor(Color.TRANSPARENT)
|
||||
// }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package io.legato.kazusa.ui.widget.checkbox
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.widget.Checkable
|
||||
import androidx.core.view.postDelayed
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.lib.theme.ThemeStore
|
||||
import io.legato.kazusa.utils.dpToPx
|
||||
import io.legato.kazusa.utils.getCompatColor
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class SmoothCheckBox @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs), Checkable {
|
||||
private var mPaint: Paint
|
||||
private var mTickPaint: Paint
|
||||
private var mFloorPaint: Paint
|
||||
private var mTickPoints: Array<Point>
|
||||
private var mCenterPoint: Point
|
||||
private var mTickPath: Path
|
||||
private var mLeftLineDistance = 0f
|
||||
private var mRightLineDistance = 0f
|
||||
private var mDrewDistance = 0f
|
||||
private var mScaleVal = 1.0f
|
||||
private var mFloorScale = 1.0f
|
||||
private var mWidth = 0
|
||||
private var mAnimDuration = 0
|
||||
private var mStrokeWidth = 0
|
||||
private var mCheckedColor = 0
|
||||
private var mUnCheckedColor = 0
|
||||
private var mFloorColor = 0
|
||||
private var mFloorUnCheckedColor = 0
|
||||
private var mChecked = false
|
||||
private var mTickDrawing = false
|
||||
var onCheckedChangeListener: ((checkBox: SmoothCheckBox, isChecked: Boolean) -> Unit)? = null
|
||||
|
||||
init {
|
||||
val ta = context.obtainStyledAttributes(attrs, R.styleable.SmoothCheckBox)
|
||||
var tickColor = ThemeStore.accentColor(context)
|
||||
//mCheckedColor = context.getCompatColor(R.color.background_menu)
|
||||
//mUnCheckedColor = context.getCompatColor(R.color.background_menu)
|
||||
mFloorColor = context.getCompatColor(R.color.transparent30)
|
||||
tickColor = ta.getColor(R.styleable.SmoothCheckBox_color_tick, tickColor)
|
||||
mAnimDuration = ta.getInt(R.styleable.SmoothCheckBox_duration, DEF_ANIM_DURATION)
|
||||
mFloorColor = ta.getColor(R.styleable.SmoothCheckBox_color_unchecked_stroke, mFloorColor)
|
||||
mCheckedColor = ta.getColor(R.styleable.SmoothCheckBox_color_checked, mCheckedColor)
|
||||
mUnCheckedColor = ta.getColor(R.styleable.SmoothCheckBox_color_unchecked, mUnCheckedColor)
|
||||
mStrokeWidth = ta.getDimensionPixelSize(R.styleable.SmoothCheckBox_stroke_width, 0)
|
||||
ta.recycle()
|
||||
mFloorUnCheckedColor = mFloorColor
|
||||
mTickPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
mTickPaint.style = Paint.Style.STROKE
|
||||
mTickPaint.strokeCap = Paint.Cap.ROUND
|
||||
mTickPaint.color = tickColor
|
||||
mFloorPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
mFloorPaint.style = Paint.Style.FILL
|
||||
mFloorPaint.color = mFloorColor
|
||||
mPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
mPaint.style = Paint.Style.FILL
|
||||
mPaint.color = mCheckedColor
|
||||
mTickPath = Path()
|
||||
mCenterPoint = Point()
|
||||
mTickPoints = arrayOf(Point(), Point(), Point())
|
||||
setOnClickListener {
|
||||
toggle()
|
||||
mTickDrawing = false
|
||||
mDrewDistance = 0f
|
||||
if (isChecked) {
|
||||
startCheckedAnimation()
|
||||
} else {
|
||||
startUnCheckedAnimation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isChecked(): Boolean {
|
||||
return mChecked
|
||||
}
|
||||
|
||||
override fun setChecked(checked: Boolean) {
|
||||
mChecked = checked
|
||||
reset()
|
||||
invalidate()
|
||||
onCheckedChangeListener?.invoke(this@SmoothCheckBox, mChecked)
|
||||
}
|
||||
|
||||
override fun toggle() {
|
||||
this.isChecked = !isChecked
|
||||
}
|
||||
|
||||
/**
|
||||
* checked with animation
|
||||
*
|
||||
* @param checked checked
|
||||
* @param animate change with animation
|
||||
*/
|
||||
fun setChecked(checked: Boolean, animate: Boolean) {
|
||||
if (animate) {
|
||||
mTickDrawing = false
|
||||
mChecked = checked
|
||||
mDrewDistance = 0f
|
||||
if (checked) {
|
||||
startCheckedAnimation()
|
||||
} else {
|
||||
startUnCheckedAnimation()
|
||||
}
|
||||
onCheckedChangeListener?.invoke(this@SmoothCheckBox, mChecked)
|
||||
} else {
|
||||
this.isChecked = checked
|
||||
}
|
||||
}
|
||||
|
||||
private fun reset() {
|
||||
mTickDrawing = true
|
||||
mFloorScale = 1.0f
|
||||
mScaleVal = if (isChecked) 0f else 1.0f
|
||||
mFloorColor = if (isChecked) mCheckedColor else mFloorUnCheckedColor
|
||||
mDrewDistance = if (isChecked) mLeftLineDistance + mRightLineDistance else 0f
|
||||
}
|
||||
|
||||
private fun measureSize(measureSpec: Int): Int {
|
||||
val defSize: Int = DEF_DRAW_SIZE.dpToPx()
|
||||
val specSize = MeasureSpec.getSize(measureSpec)
|
||||
val specMode = MeasureSpec.getMode(measureSpec)
|
||||
var result = 0
|
||||
when (specMode) {
|
||||
MeasureSpec.UNSPECIFIED, MeasureSpec.AT_MOST -> result = min(defSize, specSize)
|
||||
MeasureSpec.EXACTLY -> result = specSize
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
setMeasuredDimension(measureSize(widthMeasureSpec), measureSize(heightMeasureSpec))
|
||||
}
|
||||
|
||||
override fun onLayout(
|
||||
changed: Boolean,
|
||||
left: Int,
|
||||
top: Int,
|
||||
right: Int,
|
||||
bottom: Int
|
||||
) {
|
||||
mWidth = measuredWidth
|
||||
mStrokeWidth = if (mStrokeWidth == 0) measuredWidth / 10 else mStrokeWidth
|
||||
mStrokeWidth =
|
||||
if (mStrokeWidth > measuredWidth / 5) measuredWidth / 5 else mStrokeWidth
|
||||
mStrokeWidth = if (mStrokeWidth < 3) 3 else mStrokeWidth
|
||||
mCenterPoint.x = mWidth / 2
|
||||
mCenterPoint.y = measuredHeight / 2
|
||||
mTickPoints[0].x = (measuredWidth.toFloat() / 30 * 7).roundToInt()
|
||||
mTickPoints[0].y = (measuredHeight.toFloat() / 30 * 14).roundToInt()
|
||||
mTickPoints[1].x = (measuredWidth.toFloat() / 30 * 13).roundToInt()
|
||||
mTickPoints[1].y = (measuredHeight.toFloat() / 30 * 20).roundToInt()
|
||||
mTickPoints[2].x = (measuredWidth.toFloat() / 30 * 22).roundToInt()
|
||||
mTickPoints[2].y = (measuredHeight.toFloat() / 30 * 10).roundToInt()
|
||||
mLeftLineDistance = sqrt(
|
||||
(mTickPoints[1].x - mTickPoints[0].x.toDouble()).pow(2.0) +
|
||||
(mTickPoints[1].y - mTickPoints[0].y.toDouble()).pow(2.0)
|
||||
).toFloat()
|
||||
mRightLineDistance = sqrt(
|
||||
(mTickPoints[2].x - mTickPoints[1].x.toDouble()).pow(2.0) +
|
||||
(mTickPoints[2].y - mTickPoints[1].y.toDouble()).pow(2.0)
|
||||
).toFloat()
|
||||
mTickPaint.strokeWidth = mStrokeWidth.toFloat()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
drawBorder(canvas)
|
||||
drawCenter(canvas)
|
||||
drawTick(canvas)
|
||||
}
|
||||
|
||||
private fun drawCenter(canvas: Canvas) {
|
||||
mPaint.color = mUnCheckedColor
|
||||
val radius = (mCenterPoint.x - mStrokeWidth) * mScaleVal
|
||||
canvas.drawCircle(mCenterPoint.x.toFloat(), mCenterPoint.y.toFloat(), radius, mPaint)
|
||||
}
|
||||
|
||||
private fun drawBorder(canvas: Canvas) {
|
||||
mFloorPaint.color = mFloorColor
|
||||
val radius = mCenterPoint.x
|
||||
canvas.drawCircle(
|
||||
mCenterPoint.x.toFloat(),
|
||||
mCenterPoint.y.toFloat(),
|
||||
radius * mFloorScale,
|
||||
mFloorPaint
|
||||
)
|
||||
}
|
||||
|
||||
private fun drawTick(canvas: Canvas) {
|
||||
if (mTickDrawing && isChecked) {
|
||||
drawTickPath(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawTickPath(canvas: Canvas) {
|
||||
mTickPath.reset()
|
||||
// draw left of the tick
|
||||
if (mDrewDistance < mLeftLineDistance) {
|
||||
val step: Float = if (mWidth / 20.0f < 3) 3f else mWidth / 20.0f
|
||||
mDrewDistance += step
|
||||
val stopX =
|
||||
mTickPoints[0].x + (mTickPoints[1].x - mTickPoints[0].x) * mDrewDistance / mLeftLineDistance
|
||||
val stopY =
|
||||
mTickPoints[0].y + (mTickPoints[1].y - mTickPoints[0].y) * mDrewDistance / mLeftLineDistance
|
||||
mTickPath.moveTo(mTickPoints[0].x.toFloat(), mTickPoints[0].y.toFloat())
|
||||
mTickPath.lineTo(stopX, stopY)
|
||||
canvas.drawPath(mTickPath, mTickPaint)
|
||||
if (mDrewDistance > mLeftLineDistance) {
|
||||
mDrewDistance = mLeftLineDistance
|
||||
}
|
||||
} else {
|
||||
mTickPath.moveTo(mTickPoints[0].x.toFloat(), mTickPoints[0].y.toFloat())
|
||||
mTickPath.lineTo(mTickPoints[1].x.toFloat(), mTickPoints[1].y.toFloat())
|
||||
canvas.drawPath(mTickPath, mTickPaint)
|
||||
// draw right of the tick
|
||||
if (mDrewDistance < mLeftLineDistance + mRightLineDistance) {
|
||||
val stopX =
|
||||
mTickPoints[1].x + (mTickPoints[2].x - mTickPoints[1].x) * (mDrewDistance - mLeftLineDistance) / mRightLineDistance
|
||||
val stopY =
|
||||
mTickPoints[1].y - (mTickPoints[1].y - mTickPoints[2].y) * (mDrewDistance - mLeftLineDistance) / mRightLineDistance
|
||||
mTickPath.reset()
|
||||
mTickPath.moveTo(mTickPoints[1].x.toFloat(), mTickPoints[1].y.toFloat())
|
||||
mTickPath.lineTo(stopX, stopY)
|
||||
canvas.drawPath(mTickPath, mTickPaint)
|
||||
val step: Float = if (mWidth / 20f < 3) 3f else mWidth / 20f
|
||||
mDrewDistance += step
|
||||
} else {
|
||||
mTickPath.reset()
|
||||
mTickPath.moveTo(mTickPoints[1].x.toFloat(), mTickPoints[1].y.toFloat())
|
||||
mTickPath.lineTo(mTickPoints[2].x.toFloat(), mTickPoints[2].y.toFloat())
|
||||
canvas.drawPath(mTickPath, mTickPaint)
|
||||
}
|
||||
}
|
||||
// invalidate
|
||||
if (mDrewDistance < mLeftLineDistance + mRightLineDistance) {
|
||||
postDelayed(10) { this.postInvalidate() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCheckedAnimation() {
|
||||
val animator = ValueAnimator.ofFloat(1.0f, 0f)
|
||||
animator.duration = mAnimDuration / 3 * 2.toLong()
|
||||
animator.interpolator = LinearInterpolator()
|
||||
animator.addUpdateListener { animation: ValueAnimator ->
|
||||
mScaleVal = animation.animatedValue as Float
|
||||
mFloorColor = getGradientColor(
|
||||
mUnCheckedColor,
|
||||
mCheckedColor,
|
||||
1 - mScaleVal
|
||||
)
|
||||
postInvalidate()
|
||||
}
|
||||
animator.start()
|
||||
val floorAnimator = ValueAnimator.ofFloat(1.0f, 0.8f, 1.0f)
|
||||
floorAnimator.duration = mAnimDuration.toLong()
|
||||
floorAnimator.interpolator = LinearInterpolator()
|
||||
floorAnimator.addUpdateListener { animation: ValueAnimator ->
|
||||
mFloorScale = animation.animatedValue as Float
|
||||
postInvalidate()
|
||||
}
|
||||
floorAnimator.start()
|
||||
drawTickDelayed()
|
||||
}
|
||||
|
||||
private fun startUnCheckedAnimation() {
|
||||
val animator = ValueAnimator.ofFloat(0f, 1.0f)
|
||||
animator.duration = mAnimDuration.toLong()
|
||||
animator.interpolator = LinearInterpolator()
|
||||
animator.addUpdateListener { animation: ValueAnimator ->
|
||||
mScaleVal = animation.animatedValue as Float
|
||||
mFloorColor = getGradientColor(
|
||||
mCheckedColor,
|
||||
mFloorUnCheckedColor,
|
||||
mScaleVal
|
||||
)
|
||||
postInvalidate()
|
||||
}
|
||||
animator.start()
|
||||
val floorAnimator = ValueAnimator.ofFloat(1.0f, 0.8f, 1.0f)
|
||||
floorAnimator.duration = mAnimDuration.toLong()
|
||||
floorAnimator.interpolator = LinearInterpolator()
|
||||
floorAnimator.addUpdateListener { animation: ValueAnimator ->
|
||||
mFloorScale = animation.animatedValue as Float
|
||||
postInvalidate()
|
||||
}
|
||||
floorAnimator.start()
|
||||
}
|
||||
|
||||
private fun drawTickDelayed() {
|
||||
postDelayed(mAnimDuration.toLong()) {
|
||||
mTickDrawing = true
|
||||
postInvalidate()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEF_DRAW_SIZE = 25
|
||||
private const val DEF_ANIM_DURATION = 300
|
||||
private fun getGradientColor(startColor: Int, endColor: Int, percent: Float): Int {
|
||||
val startA = Color.alpha(startColor)
|
||||
val startR = Color.red(startColor)
|
||||
val startG = Color.green(startColor)
|
||||
val startB = Color.blue(startColor)
|
||||
val endA = Color.alpha(endColor)
|
||||
val endR = Color.red(endColor)
|
||||
val endG = Color.green(endColor)
|
||||
val endB = Color.blue(endColor)
|
||||
val currentA = (startA * (1 - percent) + endA * percent).toInt()
|
||||
val currentR = (startR * (1 - percent) + endR * percent).toInt()
|
||||
val currentG = (startG * (1 - percent) + endG * percent).toInt()
|
||||
val currentB = (startB * (1 - percent) + endB * percent).toInt()
|
||||
return Color.argb(currentA, currentR, currentG, currentB)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package io.legato.kazusa.ui.widget.code
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Paint.FontMetricsInt
|
||||
import android.graphics.Rect
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.*
|
||||
import android.text.style.BackgroundColorSpan
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.ReplacementSpan
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.ColorInt
|
||||
import io.legato.kazusa.ui.widget.text.ScrollMultiAutoCompleteTextView
|
||||
import java.util.*
|
||||
import java.util.regex.Matcher
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Suppress("unused")
|
||||
class CodeView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
|
||||
ScrollMultiAutoCompleteTextView(context, attrs) {
|
||||
|
||||
private var tabWidth = 0
|
||||
private var tabWidthInCharacters = 0
|
||||
private var mUpdateDelayTime = 500
|
||||
private var modified = true
|
||||
private var highlightWhileTextChanging = true
|
||||
private var hasErrors = false
|
||||
private var mRemoveErrorsWhenTextChanged = true
|
||||
private val mUpdateHandler = Handler(Looper.getMainLooper())
|
||||
//private var mAutoCompleteTokenizer: Tokenizer? = null
|
||||
private val displayDensity = resources.displayMetrics.density
|
||||
private val mErrorHashSet: SortedMap<Int, Int> = TreeMap()
|
||||
private val mSyntaxPatternMap: MutableMap<Pattern, Int> = HashMap()
|
||||
private var mIndentCharacterList = mutableListOf('{', '+', '-', '*', '/', '=')
|
||||
|
||||
private val mUpdateRunnable = Runnable {
|
||||
val source = text
|
||||
highlightWithoutChange(source)
|
||||
}
|
||||
|
||||
private val mEditorTextWatcher: TextWatcher = object : TextWatcher {
|
||||
private var start = 0
|
||||
private var count = 0
|
||||
override fun beforeTextChanged(
|
||||
charSequence: CharSequence,
|
||||
start: Int,
|
||||
before: Int,
|
||||
count: Int
|
||||
) {
|
||||
this.start = start
|
||||
this.count = count
|
||||
}
|
||||
|
||||
override fun onTextChanged(
|
||||
charSequence: CharSequence,
|
||||
start: Int,
|
||||
before: Int,
|
||||
count: Int
|
||||
) {
|
||||
if (!modified) return
|
||||
if (highlightWhileTextChanging) {
|
||||
if (mSyntaxPatternMap.isNotEmpty()) {
|
||||
convertTabs(editableText, start, count)
|
||||
mUpdateHandler.postDelayed(mUpdateRunnable, mUpdateDelayTime.toLong())
|
||||
}
|
||||
}
|
||||
if (mRemoveErrorsWhenTextChanged) removeAllErrorLines()
|
||||
}
|
||||
|
||||
override fun afterTextChanged(editable: Editable) {
|
||||
if (!highlightWhileTextChanging) {
|
||||
if (!modified) return
|
||||
cancelHighlighterRender()
|
||||
if (mSyntaxPatternMap.isNotEmpty()) {
|
||||
convertTabs(editableText, start, count)
|
||||
mUpdateHandler.postDelayed(mUpdateRunnable, mUpdateDelayTime.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// if (mAutoCompleteTokenizer == null) {
|
||||
// mAutoCompleteTokenizer = KeywordTokenizer()
|
||||
// }
|
||||
// setTokenizer(mAutoCompleteTokenizer)
|
||||
filters = arrayOf(
|
||||
InputFilter { source, start, end, dest, dStart, dEnd ->
|
||||
if (modified && end - start == 1 && start < source.length && dStart < dest.length) {
|
||||
val c = source[start]
|
||||
if (c == '\n') {
|
||||
return@InputFilter autoIndent(source, dest, dStart, dEnd)
|
||||
}
|
||||
}
|
||||
source
|
||||
}
|
||||
)
|
||||
addTextChangedListener(mEditorTextWatcher)
|
||||
}
|
||||
|
||||
override fun showDropDown() {
|
||||
val screenPoint = IntArray(2)
|
||||
getLocationOnScreen(screenPoint)
|
||||
val displayFrame = Rect()
|
||||
getWindowVisibleDisplayFrame(displayFrame)
|
||||
val position = selectionStart
|
||||
val layout = layout
|
||||
val line = layout.getLineForOffset(position)
|
||||
val verticalDistanceInDp = (750 + 140 * line) / displayDensity
|
||||
dropDownVerticalOffset = verticalDistanceInDp.toInt()
|
||||
val horizontalDistanceInDp = layout.getPrimaryHorizontal(position) / displayDensity
|
||||
dropDownHorizontalOffset = horizontalDistanceInDp.toInt()
|
||||
super.showDropDown()
|
||||
}
|
||||
|
||||
private fun autoIndent(
|
||||
source: CharSequence,
|
||||
dest: Spanned,
|
||||
dStart: Int,
|
||||
dEnd: Int
|
||||
): CharSequence {
|
||||
var indent = ""
|
||||
var iStart = dStart - 1
|
||||
var dataBefore = false
|
||||
var pt = 0
|
||||
while (iStart > -1) {
|
||||
val c = dest[iStart]
|
||||
if (c == '\n') break
|
||||
if (c != ' ' && c != '\t') {
|
||||
if (!dataBefore) {
|
||||
if (mIndentCharacterList.contains(c)) --pt
|
||||
dataBefore = true
|
||||
}
|
||||
if (c == '(') {
|
||||
--pt
|
||||
} else if (c == ')') {
|
||||
++pt
|
||||
}
|
||||
}
|
||||
--iStart
|
||||
}
|
||||
if (iStart > -1) {
|
||||
val charAtCursor = dest[dStart]
|
||||
var iEnd: Int = ++iStart
|
||||
while (iEnd < dEnd) {
|
||||
val c = dest[iEnd]
|
||||
if (charAtCursor != '\n' && c == '/' && iEnd + 1 < dEnd && dest[iEnd] == c) {
|
||||
iEnd += 2
|
||||
break
|
||||
}
|
||||
if (c != ' ' && c != '\t') {
|
||||
break
|
||||
}
|
||||
++iEnd
|
||||
}
|
||||
indent += dest.subSequence(iStart, iEnd)
|
||||
}
|
||||
if (pt < 0) {
|
||||
indent += "\t"
|
||||
}
|
||||
return source.toString() + indent
|
||||
}
|
||||
|
||||
private fun highlightSyntax(editable: Editable) {
|
||||
if (mSyntaxPatternMap.isEmpty()) return
|
||||
for (pattern in mSyntaxPatternMap.keys) {
|
||||
val color = mSyntaxPatternMap[pattern]!!
|
||||
val m = pattern.matcher(editable)
|
||||
while (m.find()) {
|
||||
createForegroundColorSpan(editable, m, color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun highlightErrorLines(editable: Editable) {
|
||||
if (mErrorHashSet.isEmpty()) return
|
||||
val maxErrorLineValue = mErrorHashSet.lastKey()
|
||||
var lineNumber = 0
|
||||
val matcher = PATTERN_LINE.matcher(editable)
|
||||
while (matcher.find()) {
|
||||
if (mErrorHashSet.containsKey(lineNumber)) {
|
||||
val color = mErrorHashSet[lineNumber]!!
|
||||
createBackgroundColorSpan(editable, matcher, color)
|
||||
}
|
||||
lineNumber += 1
|
||||
if (lineNumber > maxErrorLineValue) break
|
||||
}
|
||||
}
|
||||
|
||||
private fun createForegroundColorSpan(
|
||||
editable: Editable,
|
||||
matcher: Matcher,
|
||||
@ColorInt color: Int
|
||||
) {
|
||||
editable.setSpan(
|
||||
ForegroundColorSpan(color),
|
||||
matcher.start(), matcher.end(),
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createBackgroundColorSpan(
|
||||
editable: Editable,
|
||||
matcher: Matcher,
|
||||
@ColorInt color: Int
|
||||
) {
|
||||
editable.setSpan(
|
||||
BackgroundColorSpan(color),
|
||||
matcher.start(), matcher.end(),
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
|
||||
private fun highlight(editable: Editable): Editable {
|
||||
// if (editable.isEmpty() || editable.length > 1024) return editable
|
||||
if (editable.length !in 1..1024) {
|
||||
return editable
|
||||
}
|
||||
try {
|
||||
clearSpans(editable)
|
||||
highlightErrorLines(editable)
|
||||
highlightSyntax(editable)
|
||||
} catch (e: IllegalStateException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return editable
|
||||
}
|
||||
|
||||
private fun highlightWithoutChange(editable: Editable) {
|
||||
modified = false
|
||||
highlight(editable)
|
||||
modified = true
|
||||
}
|
||||
|
||||
fun setTextHighlighted(text: CharSequence?) {
|
||||
if (text.isNullOrEmpty()) return
|
||||
cancelHighlighterRender()
|
||||
removeAllErrorLines()
|
||||
modified = false
|
||||
setText(highlight(SpannableStringBuilder(text)))
|
||||
modified = true
|
||||
}
|
||||
|
||||
fun setTabWidth(characters: Int) {
|
||||
if (tabWidthInCharacters == characters) return
|
||||
tabWidthInCharacters = characters
|
||||
tabWidth = (paint.measureText("m") * characters).roundToInt()
|
||||
}
|
||||
|
||||
private fun clearSpans(editable: Editable) {
|
||||
val length = editable.length
|
||||
val foregroundSpans = editable.getSpans(
|
||||
0, length, ForegroundColorSpan::class.java
|
||||
)
|
||||
run {
|
||||
var i = foregroundSpans.size
|
||||
while (i-- > 0) {
|
||||
editable.removeSpan(foregroundSpans[i])
|
||||
}
|
||||
}
|
||||
val backgroundSpans = editable.getSpans(
|
||||
0, length, BackgroundColorSpan::class.java
|
||||
)
|
||||
var i = backgroundSpans.size
|
||||
while (i-- > 0) {
|
||||
editable.removeSpan(backgroundSpans[i])
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelHighlighterRender() {
|
||||
mUpdateHandler.removeCallbacks(mUpdateRunnable)
|
||||
}
|
||||
|
||||
private fun convertTabs(editable: Editable, start: Int, count: Int) {
|
||||
var startIndex = start
|
||||
if (tabWidth < 1) return
|
||||
val s = editable.toString()
|
||||
val stop = startIndex + count
|
||||
while (s.indexOf("\t", startIndex).also { startIndex = it } > -1 && startIndex < stop) {
|
||||
editable.setSpan(
|
||||
TabWidthSpan(),
|
||||
startIndex,
|
||||
startIndex + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
++startIndex
|
||||
}
|
||||
}
|
||||
|
||||
fun setSyntaxPatternsMap(syntaxPatterns: Map<Pattern, Int>?) {
|
||||
if (mSyntaxPatternMap.isNotEmpty()) mSyntaxPatternMap.clear()
|
||||
mSyntaxPatternMap.putAll(syntaxPatterns!!)
|
||||
}
|
||||
|
||||
fun addSyntaxPattern(pattern: Pattern, @ColorInt Color: Int) {
|
||||
mSyntaxPatternMap[pattern] = Color
|
||||
}
|
||||
|
||||
fun removeSyntaxPattern(pattern: Pattern) {
|
||||
mSyntaxPatternMap.remove(pattern)
|
||||
}
|
||||
|
||||
fun getSyntaxPatternsSize(): Int {
|
||||
return mSyntaxPatternMap.size
|
||||
}
|
||||
|
||||
fun resetSyntaxPatternList() {
|
||||
mSyntaxPatternMap.clear()
|
||||
}
|
||||
|
||||
fun setAutoIndentCharacterList(characterList: MutableList<Char>) {
|
||||
mIndentCharacterList = characterList
|
||||
}
|
||||
|
||||
fun clearAutoIndentCharacterList() {
|
||||
mIndentCharacterList.clear()
|
||||
}
|
||||
|
||||
fun getAutoIndentCharacterList(): List<Char> {
|
||||
return mIndentCharacterList
|
||||
}
|
||||
|
||||
fun addErrorLine(lineNum: Int, color: Int) {
|
||||
mErrorHashSet[lineNum] = color
|
||||
hasErrors = true
|
||||
}
|
||||
|
||||
fun removeErrorLine(lineNum: Int) {
|
||||
mErrorHashSet.remove(lineNum)
|
||||
hasErrors = mErrorHashSet.isNotEmpty()
|
||||
}
|
||||
|
||||
fun removeAllErrorLines() {
|
||||
mErrorHashSet.clear()
|
||||
hasErrors = false
|
||||
}
|
||||
|
||||
fun getErrorsSize(): Int {
|
||||
return mErrorHashSet.size
|
||||
}
|
||||
|
||||
fun getTextWithoutTrailingSpace(): String {
|
||||
return PATTERN_TRAILING_WHITE_SPACE
|
||||
.matcher(text)
|
||||
.replaceAll("")
|
||||
}
|
||||
|
||||
// fun setAutoCompleteTokenizer(tokenizer: Tokenizer?) {
|
||||
// mAutoCompleteTokenizer = tokenizer
|
||||
// }
|
||||
|
||||
fun setRemoveErrorsWhenTextChanged(removeErrors: Boolean) {
|
||||
mRemoveErrorsWhenTextChanged = removeErrors
|
||||
}
|
||||
|
||||
fun reHighlightSyntax() {
|
||||
highlightSyntax(editableText)
|
||||
}
|
||||
|
||||
fun reHighlightErrors() {
|
||||
highlightErrorLines(editableText)
|
||||
}
|
||||
|
||||
fun isHasError(): Boolean {
|
||||
return hasErrors
|
||||
}
|
||||
|
||||
fun setUpdateDelayTime(time: Int) {
|
||||
mUpdateDelayTime = time
|
||||
}
|
||||
|
||||
fun getUpdateDelayTime(): Int {
|
||||
return mUpdateDelayTime
|
||||
}
|
||||
|
||||
fun setHighlightWhileTextChanging(updateWhileTextChanging: Boolean) {
|
||||
highlightWhileTextChanging = updateWhileTextChanging
|
||||
}
|
||||
|
||||
private inner class TabWidthSpan : ReplacementSpan() {
|
||||
override fun getSize(
|
||||
paint: Paint,
|
||||
text: CharSequence,
|
||||
start: Int,
|
||||
end: Int,
|
||||
fm: FontMetricsInt?
|
||||
): Int {
|
||||
return tabWidth
|
||||
}
|
||||
|
||||
override fun draw(
|
||||
canvas: Canvas,
|
||||
text: CharSequence,
|
||||
start: Int,
|
||||
end: Int,
|
||||
x: Float,
|
||||
top: Int,
|
||||
y: Int,
|
||||
bottom: Int,
|
||||
paint: Paint
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PATTERN_LINE = Pattern.compile("(^.+$)+", Pattern.MULTILINE)
|
||||
private val PATTERN_TRAILING_WHITE_SPACE = Pattern.compile("[\\t ]+$", Pattern.MULTILINE)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package io.legato.kazusa.ui.widget.code
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.ArrayAdapter
|
||||
import io.legato.kazusa.R
|
||||
import splitties.init.appCtx
|
||||
import splitties.resources.color
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val legadoPattern: Pattern = Pattern.compile("\\|\\||&&|%%|@js:|@Json:|@css:|@@|@XPath:")
|
||||
val jsonPattern: Pattern = Pattern.compile("\"[A-Za-z0-9]*?\"\\:|\"|\\{|\\}|\\[|\\]")
|
||||
val wrapPattern: Pattern = Pattern.compile("\\\\n")
|
||||
val operationPattern: Pattern =
|
||||
Pattern.compile(":|==|>|<|!=|>=|<=|->|=|%|-|-=|%=|\\+|\\-|\\-=|\\+=|\\^|\\&|\\|::|\\?|\\*")
|
||||
val jsPattern: Pattern = Pattern.compile("var")
|
||||
|
||||
fun CodeView.addLegadoPattern() {
|
||||
addSyntaxPattern(legadoPattern, appCtx.color(R.color.md_orange_900))
|
||||
}
|
||||
|
||||
fun CodeView.addJsonPattern() {
|
||||
addSyntaxPattern(jsonPattern, appCtx.color(R.color.md_blue_800))
|
||||
}
|
||||
|
||||
fun CodeView.addJsPattern() {
|
||||
addSyntaxPattern(wrapPattern, appCtx.color(R.color.md_blue_grey_500))
|
||||
addSyntaxPattern(operationPattern, appCtx.color(R.color.md_orange_900))
|
||||
addSyntaxPattern(jsPattern, appCtx.color(R.color.md_light_blue_600))
|
||||
}
|
||||
|
||||
fun Context.arrayAdapter(keywords: Array<String>): ArrayAdapter<String> {
|
||||
return ArrayAdapter(this, R.layout.item_1line_text_and_del, R.id.text_view, keywords)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.legato.kazusa.ui.widget.code
|
||||
|
||||
import android.widget.MultiAutoCompleteTextView
|
||||
import kotlin.math.max
|
||||
|
||||
class KeywordTokenizer : MultiAutoCompleteTextView.Tokenizer {
|
||||
override fun findTokenStart(charSequence: CharSequence, cursor: Int): Int {
|
||||
var sequenceStr = charSequence.toString()
|
||||
sequenceStr = sequenceStr.substring(0, cursor)
|
||||
val spaceIndex = sequenceStr.lastIndexOf(" ")
|
||||
val lineIndex = sequenceStr.lastIndexOf("\n")
|
||||
val bracketIndex = sequenceStr.lastIndexOf("(")
|
||||
val index = max(0, max(spaceIndex, max(lineIndex, bracketIndex)))
|
||||
if (index == 0) return 0
|
||||
return if (index + 1 < charSequence.length) index + 1 else index
|
||||
}
|
||||
|
||||
override fun findTokenEnd(charSequence: CharSequence, cursor: Int): Int {
|
||||
return charSequence.length
|
||||
}
|
||||
|
||||
override fun terminateToken(charSequence: CharSequence): CharSequence {
|
||||
return charSequence
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package io.legato.kazusa.ui.widget.dialog
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.base.BaseDialogFragment
|
||||
import io.legato.kazusa.databinding.DialogCodeViewBinding
|
||||
import io.legato.kazusa.help.IntentData
|
||||
//import io.legado.app.lib.theme.primaryColor
|
||||
import io.legato.kazusa.ui.widget.code.addJsPattern
|
||||
import io.legato.kazusa.ui.widget.code.addJsonPattern
|
||||
import io.legato.kazusa.ui.widget.code.addLegadoPattern
|
||||
import io.legato.kazusa.utils.applyTint
|
||||
import io.legato.kazusa.utils.disableEdit
|
||||
import io.legato.kazusa.utils.setLayout
|
||||
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
|
||||
|
||||
class CodeDialog() : BaseDialogFragment(R.layout.dialog_code_view) {
|
||||
|
||||
constructor(code: String, disableEdit: Boolean = true, requestId: String? = null) : this() {
|
||||
arguments = Bundle().apply {
|
||||
putBoolean("disableEdit", disableEdit)
|
||||
putString("code", IntentData.put(code))
|
||||
putString("requestId", requestId)
|
||||
}
|
||||
}
|
||||
|
||||
val binding by viewBinding(DialogCodeViewBinding::bind)
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
|
||||
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
|
||||
//binding.toolBar.setBackgroundColor(primaryColor)
|
||||
if (arguments?.getBoolean("disableEdit") == true) {
|
||||
binding.toolBar.title = "code view"
|
||||
binding.codeView.disableEdit()
|
||||
} else {
|
||||
initMenu()
|
||||
}
|
||||
binding.codeView.addLegadoPattern()
|
||||
binding.codeView.addJsonPattern()
|
||||
binding.codeView.addJsPattern()
|
||||
arguments?.getString("code")?.let {
|
||||
binding.codeView.text = IntentData.get(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initMenu() {
|
||||
binding.toolBar.inflateMenu(R.menu.code_edit)
|
||||
binding.toolBar.menu.applyTint(requireContext())
|
||||
binding.toolBar.setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.menu_save -> {
|
||||
binding.codeView.text?.toString()?.let { code ->
|
||||
val requestId = arguments?.getString("requestId")
|
||||
(parentFragment as? Callback)?.onCodeSave(code, requestId)
|
||||
?: (activity as? Callback)?.onCodeSave(code, requestId)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
interface Callback {
|
||||
|
||||
fun onCodeSave(code: String, requestId: String?)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package io.legato.kazusa.ui.widget.dialog
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.base.BaseDialogFragment
|
||||
import io.legato.kazusa.databinding.DialogPhotoViewBinding
|
||||
import io.legato.kazusa.help.book.BookHelp
|
||||
import io.legato.kazusa.help.glide.ImageLoader
|
||||
import io.legato.kazusa.help.glide.OkHttpModelLoader
|
||||
import io.legato.kazusa.model.BookCover
|
||||
import io.legato.kazusa.model.ImageProvider
|
||||
import io.legato.kazusa.model.ReadBook
|
||||
import io.legato.kazusa.utils.setLayout
|
||||
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
|
||||
|
||||
/**
|
||||
* 显示图片
|
||||
*/
|
||||
class PhotoDialog() : BaseDialogFragment(R.layout.dialog_photo_view) {
|
||||
|
||||
constructor(src: String, sourceOrigin: String? = null) : this() {
|
||||
arguments = Bundle().apply {
|
||||
putString("src", src)
|
||||
putString("sourceOrigin", sourceOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
private val binding by viewBinding(DialogPhotoViewBinding::bind)
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val arguments = arguments ?: return
|
||||
val src = arguments.getString("src") ?: return
|
||||
ImageProvider.get(src)?.let {
|
||||
binding.photoView.setImageBitmap(it)
|
||||
return
|
||||
}
|
||||
val file = ReadBook.book?.let { book ->
|
||||
BookHelp.getImage(book, src)
|
||||
}
|
||||
if (file?.exists() == true) {
|
||||
ImageLoader.load(requireContext(), file)
|
||||
.error(R.drawable.image_loading_error)
|
||||
.dontTransform()
|
||||
.downsample(DownsampleStrategy.NONE)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.into(binding.photoView)
|
||||
} else {
|
||||
ImageLoader.load(requireContext(), src).apply {
|
||||
arguments.getString("sourceOrigin")?.let { sourceOrigin ->
|
||||
apply(RequestOptions().set(OkHttpModelLoader.sourceOriginOption, sourceOrigin))
|
||||
}
|
||||
}.error(BookCover.defaultDrawable)
|
||||
.dontTransform()
|
||||
.downsample(DownsampleStrategy.NONE)
|
||||
.into(binding.photoView)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package io.legato.kazusa.ui.widget.dialog
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.textclassifier.TextClassifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.base.BaseDialogFragment
|
||||
import io.legato.kazusa.databinding.DialogTextViewBinding
|
||||
import io.legato.kazusa.help.IntentData
|
||||
//import io.legado.app.lib.theme.primaryColor
|
||||
import io.legato.kazusa.utils.applyTint
|
||||
import io.legato.kazusa.utils.setHtml
|
||||
import io.legato.kazusa.utils.setLayout
|
||||
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
|
||||
import io.noties.markwon.Markwon
|
||||
import io.noties.markwon.ext.tables.TablePlugin
|
||||
import io.noties.markwon.html.HtmlPlugin
|
||||
import io.noties.markwon.image.glide.GlideImagesPlugin
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
class TextDialog() : BaseDialogFragment(R.layout.dialog_text_view) {
|
||||
|
||||
enum class Mode {
|
||||
MD, HTML, TEXT
|
||||
}
|
||||
|
||||
constructor(
|
||||
title: String,
|
||||
content: String?,
|
||||
mode: Mode = Mode.TEXT,
|
||||
time: Long = 0,
|
||||
autoClose: Boolean = false
|
||||
) : this() {
|
||||
arguments = Bundle().apply {
|
||||
putString("title", title)
|
||||
putString("content", IntentData.put(content))
|
||||
putString("mode", mode.name)
|
||||
putLong("time", time)
|
||||
}
|
||||
isCancelable = false
|
||||
this.autoClose = autoClose
|
||||
}
|
||||
|
||||
private val binding by viewBinding(DialogTextViewBinding::bind)
|
||||
private var time = 0L
|
||||
private var autoClose: Boolean = false
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, 0.9f)
|
||||
}
|
||||
|
||||
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
|
||||
//binding.toolBar.setBackgroundColor(primaryColor)
|
||||
binding.toolBar.inflateMenu(R.menu.dialog_text)
|
||||
binding.toolBar.menu.applyTint(requireContext())
|
||||
binding.toolBar.setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.menu_close -> dismissAllowingStateLoss()
|
||||
}
|
||||
true
|
||||
}
|
||||
arguments?.let {
|
||||
binding.toolBar.title = it.getString("title")
|
||||
val content = IntentData.get(it.getString("content")) ?: ""
|
||||
when (it.getString("mode")) {
|
||||
Mode.MD.name -> viewLifecycleOwner.lifecycleScope.launch {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
binding.textView.setTextClassifier(TextClassifier.NO_OP)
|
||||
}
|
||||
val markwon: Markwon
|
||||
val markdown = withContext(IO) {
|
||||
markwon = Markwon.builder(requireContext())
|
||||
.usePlugin(GlideImagesPlugin.create(requireContext()))
|
||||
.usePlugin(HtmlPlugin.create())
|
||||
.usePlugin(TablePlugin.create(requireContext()))
|
||||
.build()
|
||||
markwon.toMarkdown(content)
|
||||
}
|
||||
markwon.setParsedMarkdown(binding.textView, markdown)
|
||||
}
|
||||
|
||||
Mode.HTML.name -> binding.textView.setHtml(content)
|
||||
else -> {
|
||||
if (content.length >= 32 * 1024) {
|
||||
val truncatedContent =
|
||||
content.substring(0, 32 * 1024) + "\n\n数据太大,无法全部显示…"
|
||||
binding.textView.text = truncatedContent
|
||||
} else {
|
||||
binding.textView.text = content
|
||||
}
|
||||
}
|
||||
}
|
||||
time = it.getLong("time", 0L)
|
||||
}
|
||||
if (time > 0) {
|
||||
binding.badgeView.setBadgeCount((time / 1000).toInt())
|
||||
lifecycleScope.launch {
|
||||
while (time > 0) {
|
||||
delay(1000)
|
||||
time -= 1000
|
||||
binding.badgeView.setBadgeCount((time / 1000).toInt())
|
||||
if (time <= 0) {
|
||||
view.post {
|
||||
dialog?.setCancelable(true)
|
||||
if (autoClose) dialog?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view.post {
|
||||
dialog?.setCancelable(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package io.legato.kazusa.ui.widget.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.base.BaseDialogFragment
|
||||
import io.legato.kazusa.base.adapter.ItemViewHolder
|
||||
import io.legato.kazusa.base.adapter.RecyclerAdapter
|
||||
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
|
||||
import io.legato.kazusa.databinding.ItemLogBinding
|
||||
import io.legato.kazusa.utils.setLayout
|
||||
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
|
||||
|
||||
@Suppress("unused")
|
||||
class TextListDialog() : BaseDialogFragment(R.layout.dialog_recycler_view) {
|
||||
|
||||
constructor(title: String, values: ArrayList<String>) : this() {
|
||||
arguments = Bundle().apply {
|
||||
putString("title", title)
|
||||
putStringArrayList("values", values)
|
||||
}
|
||||
}
|
||||
|
||||
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
|
||||
private val adapter by lazy { TextAdapter(requireContext()) }
|
||||
private var values: ArrayList<String>? = null
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
setLayout(0.9f, 0.9f)
|
||||
}
|
||||
|
||||
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run {
|
||||
arguments?.let {
|
||||
toolBar.title = it.getString("title")
|
||||
values = it.getStringArrayList("values")
|
||||
}
|
||||
recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
recyclerView.adapter = adapter
|
||||
adapter.setItems(values)
|
||||
}
|
||||
|
||||
class TextAdapter(context: Context) :
|
||||
RecyclerAdapter<String, ItemLogBinding>(context) {
|
||||
|
||||
override fun getViewBinding(parent: ViewGroup): ItemLogBinding {
|
||||
return ItemLogBinding.inflate(inflater, parent, false)
|
||||
}
|
||||
|
||||
override fun convert(
|
||||
holder: ItemViewHolder,
|
||||
binding: ItemLogBinding,
|
||||
item: String,
|
||||
payloads: MutableList<Any>
|
||||
) {
|
||||
binding.apply {
|
||||
if (textView.getTag(R.id.tag1) == null) {
|
||||
val listener = object : View.OnAttachStateChangeListener {
|
||||
override fun onViewAttachedToWindow(v: View) {
|
||||
textView.isCursorVisible = false
|
||||
textView.isCursorVisible = true
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(v: View) {}
|
||||
}
|
||||
textView.addOnAttachStateChangeListener(listener)
|
||||
textView.setTag(R.id.tag1, listener)
|
||||
}
|
||||
textView.text = item
|
||||
}
|
||||
}
|
||||
|
||||
override fun registerListener(holder: ItemViewHolder, binding: ItemLogBinding) {
|
||||
//nothing
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package io.legato.kazusa.ui.widget.dialog
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.constant.AppConst
|
||||
import io.legato.kazusa.databinding.DialogUrlOptionEditBinding
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.setLayout
|
||||
|
||||
class UrlOptionDialog(context: Context, private val success: (String) -> Unit) : Dialog(context) {
|
||||
|
||||
val binding = DialogUrlOptionEditBinding.inflate(layoutInflater)
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
window?.setBackgroundDrawableResource(R.color.transparent)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
binding.root.setOnClickListener { dismiss() }
|
||||
binding.vwBg.setOnClickListener(null)
|
||||
binding.editMethod.setFilterValues("POST", "GET")
|
||||
binding.editCharset.setFilterValues(AppConst.charsets)
|
||||
binding.tvOk.setOnClickListener {
|
||||
success.invoke(GSON.toJson(getUrlOption()))
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUrlOption(): AnalyzeUrl.UrlOption {
|
||||
val urlOption = AnalyzeUrl.UrlOption()
|
||||
urlOption.useWebView(binding.cbUseWebView.isChecked)
|
||||
urlOption.setMethod(binding.editMethod.text.toString())
|
||||
urlOption.setCharset(binding.editCharset.text.toString())
|
||||
urlOption.setHeaders(binding.editHeaders.text.toString())
|
||||
urlOption.setBody(binding.editBody.text.toString())
|
||||
urlOption.setRetry(binding.editRetry.text.toString())
|
||||
urlOption.setType(binding.editType.text.toString())
|
||||
urlOption.setWebJs(binding.editWebJs.text.toString())
|
||||
urlOption.setJs(binding.editJs.text.toString())
|
||||
return urlOption
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package io.legato.kazusa.ui.widget.dialog
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.viewModels
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.base.BaseDialogFragment
|
||||
import io.legato.kazusa.base.BaseViewModel
|
||||
import io.legato.kazusa.databinding.DialogVariableBinding
|
||||
//import io.legado.app.lib.theme.primaryColor
|
||||
import io.legato.kazusa.utils.applyTint
|
||||
import io.legato.kazusa.utils.setLayout
|
||||
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
|
||||
|
||||
class VariableDialog() : BaseDialogFragment(R.layout.dialog_variable, true),
|
||||
Toolbar.OnMenuItemClickListener {
|
||||
|
||||
private val binding by viewBinding(DialogVariableBinding::bind)
|
||||
private val viewModel by viewModels<ViewModel>()
|
||||
|
||||
constructor(title: String, key: String, variable: String?, comment: String) : this() {
|
||||
arguments = Bundle().apply {
|
||||
putString("title", title)
|
||||
putString("key", key)
|
||||
putString("variable", variable)
|
||||
putString("comment", comment)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
|
||||
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
|
||||
//binding.toolBar.setBackgroundColor(primaryColor)
|
||||
arguments?.let {
|
||||
binding.toolBar.title = it.getString("title")
|
||||
viewModel.init(it) {
|
||||
binding.tvComment.text = viewModel.comment
|
||||
binding.tvVariable.setText(viewModel.variable)
|
||||
}
|
||||
} ?: let {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
binding.toolBar.inflateMenu(R.menu.save)
|
||||
binding.toolBar.menu.applyTint(requireContext())
|
||||
binding.toolBar.setOnMenuItemClickListener(this)
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem?): Boolean {
|
||||
when (item?.itemId) {
|
||||
R.id.menu_save -> {
|
||||
callback?.setVariable(
|
||||
viewModel.key ?: "",
|
||||
binding.tvVariable.text?.toString()
|
||||
)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
val callback get() = (parentFragment as? Callback) ?: (activity as? Callback)
|
||||
|
||||
class ViewModel(application: Application) : BaseViewModel(application) {
|
||||
|
||||
var key: String? = null
|
||||
var comment: String? = null
|
||||
var variable: String? = null
|
||||
|
||||
fun init(arguments: Bundle, onFinally: () -> Unit) {
|
||||
if (key != null) return
|
||||
execute {
|
||||
key = arguments.getString("key")
|
||||
comment = arguments.getString("comment")
|
||||
variable = arguments.getString("variable")
|
||||
}.onFinally {
|
||||
onFinally.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
|
||||
fun setVariable(key: String, variable: String?)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package io.legato.kazusa.ui.widget.dialog
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import io.legato.kazusa.databinding.DialogWaitBinding
|
||||
|
||||
|
||||
@Suppress("unused")
|
||||
class WaitDialog(context: Context) : Dialog(context) {
|
||||
|
||||
val binding = DialogWaitBinding.inflate(layoutInflater)
|
||||
|
||||
init {
|
||||
setCanceledOnTouchOutside(false)
|
||||
setContentView(binding.root)
|
||||
}
|
||||
|
||||
fun setText(text: String): WaitDialog {
|
||||
binding.tvMsg.text = text
|
||||
return this
|
||||
}
|
||||
|
||||
fun setText(res: Int): WaitDialog {
|
||||
binding.tvMsg.setText(res)
|
||||
return this
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package io.legato.kazusa.ui.widget.dynamiclayout
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewStub
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ProgressBar
|
||||
import androidx.appcompat.widget.AppCompatButton
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import io.legato.kazusa.R
|
||||
|
||||
@Suppress("unused")
|
||||
class DynamicFrameLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs), ViewSwitcher {
|
||||
|
||||
private var errorView: View? = null
|
||||
private var errorImage: AppCompatImageView? = null
|
||||
private var errorTextView: AppCompatTextView? = null
|
||||
private var actionBtn: AppCompatButton? = null
|
||||
|
||||
private var progressView: View? = null
|
||||
private var progressBar: ProgressBar? = null
|
||||
|
||||
private var contentView: View? = null
|
||||
|
||||
private var errorIcon: Drawable? = null
|
||||
private var emptyIcon: Drawable? = null
|
||||
|
||||
private var errorActionDescription: CharSequence? = null
|
||||
private var emptyActionDescription: CharSequence? = null
|
||||
private var emptyDescription: CharSequence? = null
|
||||
|
||||
private var errorAction: Action? = null
|
||||
private var emptyAction: Action? = null
|
||||
|
||||
private var changeListener: OnVisibilityChangeListener? = null
|
||||
|
||||
init {
|
||||
View.inflate(context, R.layout.view_dynamic, this)
|
||||
|
||||
val a = context.obtainStyledAttributes(attrs, R.styleable.DynamicFrameLayout)
|
||||
errorIcon = a.getDrawable(R.styleable.DynamicFrameLayout_errorSrc)
|
||||
emptyIcon = a.getDrawable(R.styleable.DynamicFrameLayout_emptySrc)
|
||||
|
||||
emptyActionDescription = a.getText(R.styleable.DynamicFrameLayout_emptyActionDescription)
|
||||
emptyDescription = a.getText(R.styleable.DynamicFrameLayout_emptyDescription)
|
||||
|
||||
errorActionDescription = a.getText(R.styleable.DynamicFrameLayout_errorActionDescription)
|
||||
if (errorActionDescription == null) {
|
||||
errorActionDescription = context.getString(R.string.dynamic_click_retry)
|
||||
}
|
||||
a.recycle()
|
||||
}
|
||||
|
||||
override fun onFinishInflate() {
|
||||
super.onFinishInflate()
|
||||
if (childCount > 2) {
|
||||
contentView = getChildAt(2)
|
||||
}
|
||||
}
|
||||
|
||||
override fun showErrorView(message: CharSequence) {
|
||||
ensureErrorView()
|
||||
|
||||
setViewVisible(errorView, true)
|
||||
setViewVisible(contentView, false)
|
||||
setViewVisible(progressView, false)
|
||||
|
||||
errorTextView?.text = message
|
||||
errorImage?.setImageDrawable(errorIcon)
|
||||
|
||||
actionBtn?.let {
|
||||
it.tag = ACTION_WHEN_ERROR
|
||||
it.visibility = View.VISIBLE
|
||||
if (errorActionDescription != null) {
|
||||
it.text = errorActionDescription
|
||||
}
|
||||
}
|
||||
|
||||
dispatchVisibilityChanged(ViewSwitcher.SHOW_ERROR_VIEW)
|
||||
}
|
||||
|
||||
override fun showErrorView(messageId: Int) {
|
||||
showErrorView(resources.getText(messageId))
|
||||
}
|
||||
|
||||
override fun showEmptyView() {
|
||||
ensureErrorView()
|
||||
|
||||
setViewVisible(errorView, true)
|
||||
setViewVisible(contentView, false)
|
||||
setViewVisible(progressView, false)
|
||||
|
||||
errorTextView?.text = emptyDescription
|
||||
errorImage?.setImageDrawable(emptyIcon)
|
||||
|
||||
actionBtn?.let {
|
||||
it.tag = ACTION_WHEN_EMPTY
|
||||
if (errorActionDescription != null) {
|
||||
it.visibility = View.VISIBLE
|
||||
it.text = errorActionDescription
|
||||
} else {
|
||||
it.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
dispatchVisibilityChanged(ViewSwitcher.SHOW_EMPTY_VIEW)
|
||||
}
|
||||
|
||||
override fun showProgressView() {
|
||||
ensureProgressView()
|
||||
|
||||
setViewVisible(errorView, false)
|
||||
setViewVisible(contentView, false)
|
||||
setViewVisible(progressView, true)
|
||||
|
||||
dispatchVisibilityChanged(ViewSwitcher.SHOW_PROGRESS_VIEW)
|
||||
}
|
||||
|
||||
override fun showContentView() {
|
||||
setViewVisible(errorView, false)
|
||||
setViewVisible(contentView, true)
|
||||
setViewVisible(progressView, false)
|
||||
|
||||
dispatchVisibilityChanged(ViewSwitcher.SHOW_CONTENT_VIEW)
|
||||
}
|
||||
|
||||
fun setOnVisibilityChangeListener(listener: OnVisibilityChangeListener) {
|
||||
changeListener = listener
|
||||
}
|
||||
|
||||
fun setErrorAction(action: Action) {
|
||||
errorAction = action
|
||||
}
|
||||
|
||||
fun setEmptyAction(action: Action) {
|
||||
emptyAction = action
|
||||
}
|
||||
|
||||
private fun setViewVisible(view: View?, visible: Boolean) {
|
||||
view?.let {
|
||||
it.visibility = if (visible) View.VISIBLE else View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureErrorView() {
|
||||
if (errorView == null) {
|
||||
errorView = findViewById<ViewStub>(R.id.error_view_stub).inflate()
|
||||
errorImage = errorView?.findViewById(R.id.iv_error_image)
|
||||
errorTextView = errorView?.findViewById(R.id.tv_error_message)
|
||||
actionBtn = errorView?.findViewById(R.id.btn_error_retry)
|
||||
|
||||
actionBtn?.setOnClickListener {
|
||||
when (it.tag) {
|
||||
ACTION_WHEN_EMPTY -> emptyAction?.onAction(this@DynamicFrameLayout)
|
||||
ACTION_WHEN_ERROR -> errorAction?.onAction(this@DynamicFrameLayout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureProgressView() {
|
||||
if (progressView == null) {
|
||||
progressView = findViewById<ViewStub>(R.id.progress_view_stub).inflate()
|
||||
progressBar = progressView?.findViewById(R.id.loading_progress)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchVisibilityChanged(@ViewSwitcher.Visibility visibility: Int) {
|
||||
changeListener?.onVisibilityChanged(visibility)
|
||||
}
|
||||
|
||||
interface Action {
|
||||
fun onAction(switcher: ViewSwitcher)
|
||||
}
|
||||
|
||||
|
||||
interface OnVisibilityChangeListener {
|
||||
|
||||
fun onVisibilityChanged(@ViewSwitcher.Visibility visibility: Int)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_WHEN_ERROR = "ACTION_WHEN_ERROR"
|
||||
private const val ACTION_WHEN_EMPTY = "ACTION_WHEN_EMPTY"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package io.legato.kazusa.ui.widget.dynamiclayout
|
||||
|
||||
import androidx.annotation.IntDef
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
interface ViewSwitcher {
|
||||
|
||||
companion object {
|
||||
const val SHOW_CONTENT_VIEW = 0
|
||||
const val SHOW_ERROR_VIEW = 1
|
||||
const val SHOW_EMPTY_VIEW = 2
|
||||
const val SHOW_PROGRESS_VIEW = 3
|
||||
}
|
||||
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
@IntDef(SHOW_CONTENT_VIEW, SHOW_ERROR_VIEW, SHOW_EMPTY_VIEW, SHOW_PROGRESS_VIEW)
|
||||
annotation class Visibility
|
||||
|
||||
fun showErrorView(message: CharSequence)
|
||||
|
||||
fun showErrorView(@StringRes messageId: Int)
|
||||
|
||||
fun showEmptyView()
|
||||
|
||||
fun showProgressView()
|
||||
|
||||
fun showContentView()
|
||||
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package io.legato.kazusa.ui.widget.image
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import io.legato.kazusa.R
|
||||
|
||||
/**
|
||||
* 弧形View
|
||||
*/
|
||||
class ArcView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs) {
|
||||
private var mWidth = 0
|
||||
private var mHeight = 0
|
||||
|
||||
//弧形高度
|
||||
private val mArcHeight: Int
|
||||
|
||||
//背景颜色
|
||||
private var mBgColor: Int
|
||||
private val mPaint: Paint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
}
|
||||
private val mDirectionTop: Boolean
|
||||
val rect = Rect()
|
||||
val path = Path()
|
||||
|
||||
init {
|
||||
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ArcView)
|
||||
mArcHeight = typedArray.getDimensionPixelSize(R.styleable.ArcView_arcHeight, 0)
|
||||
mBgColor = typedArray.getColor(
|
||||
R.styleable.ArcView_bgColor,
|
||||
Color.parseColor("#303F9F")
|
||||
)
|
||||
mDirectionTop = typedArray.getBoolean(R.styleable.ArcView_arcDirectionTop, false)
|
||||
typedArray.recycle()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
mPaint.style = Paint.Style.FILL
|
||||
mPaint.color = mBgColor
|
||||
if (mDirectionTop) {
|
||||
rect.set(0, mArcHeight, mWidth, mHeight)
|
||||
canvas.drawRect(rect, mPaint)
|
||||
path.reset()
|
||||
path.moveTo(0f, mArcHeight.toFloat())
|
||||
path.quadTo(mWidth / 2.toFloat(), 0f, mWidth.toFloat(), mArcHeight.toFloat())
|
||||
canvas.drawPath(path, mPaint)
|
||||
} else {
|
||||
rect.set(0, 0, mWidth, mHeight - mArcHeight)
|
||||
canvas.drawRect(rect, mPaint)
|
||||
path.reset()
|
||||
path.moveTo(0f, mHeight - mArcHeight.toFloat())
|
||||
path.quadTo(
|
||||
mWidth / 2.toFloat(),
|
||||
mHeight.toFloat(),
|
||||
mWidth.toFloat(),
|
||||
mHeight - mArcHeight.toFloat()
|
||||
)
|
||||
canvas.drawPath(path, mPaint)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
|
||||
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
|
||||
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
|
||||
if (widthMode == MeasureSpec.EXACTLY) {
|
||||
mWidth = widthSize
|
||||
}
|
||||
if (heightMode == MeasureSpec.EXACTLY) {
|
||||
mHeight = heightSize
|
||||
}
|
||||
setMeasuredDimension(mWidth, mHeight)
|
||||
}
|
||||
|
||||
fun setBgColor(color: Int) {
|
||||
mBgColor = color
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package io.legato.kazusa.ui.widget.image
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.text.TextPaint
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewOutlineProvider
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.utils.getCompatColor
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
import io.legato.kazusa.utils.spToPx
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
@Suppress("unused", "MemberVisibilityCanBePrivate")
|
||||
class CircleImageView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppCompatImageView(context, attrs) {
|
||||
|
||||
private val mDrawableRect = RectF()
|
||||
private val mBorderRect = RectF()
|
||||
|
||||
private val mShaderMatrix = Matrix()
|
||||
private val mBitmapPaint = Paint()
|
||||
private val mBorderPaint = Paint()
|
||||
private val mCircleBackgroundPaint = Paint()
|
||||
private val textPaint by lazy {
|
||||
val textPaint = TextPaint()
|
||||
textPaint.isAntiAlias = true
|
||||
textPaint.textAlign = Paint.Align.CENTER
|
||||
textPaint
|
||||
}
|
||||
|
||||
private var mBorderColor = DEFAULT_BORDER_COLOR
|
||||
private var mBorderWidth = DEFAULT_BORDER_WIDTH
|
||||
private var mCircleBackgroundColor = DEFAULT_CIRCLE_BACKGROUND_COLOR
|
||||
|
||||
private var mBitmap: Bitmap? = null
|
||||
private var mBitmapShader: BitmapShader? = null
|
||||
private var mBitmapWidth: Int = 0
|
||||
private var mBitmapHeight: Int = 0
|
||||
|
||||
private var mDrawableRadius: Float = 0.toFloat()
|
||||
private var mBorderRadius: Float = 0.toFloat()
|
||||
|
||||
private var mColorFilter: ColorFilter? = null
|
||||
|
||||
private var mReady: Boolean = false
|
||||
private var mSetupPending: Boolean = false
|
||||
private var mBorderOverlay: Boolean = false
|
||||
var isDisableCircularTransformation: Boolean = false
|
||||
set(disableCircularTransformation) {
|
||||
if (field == disableCircularTransformation) {
|
||||
return
|
||||
}
|
||||
field = disableCircularTransformation
|
||||
initializeBitmap()
|
||||
}
|
||||
|
||||
var borderColor: Int
|
||||
get() = mBorderColor
|
||||
set(@ColorInt borderColor) {
|
||||
if (borderColor == mBorderColor) {
|
||||
return
|
||||
}
|
||||
|
||||
mBorderColor = borderColor
|
||||
mBorderPaint.color = mBorderColor
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var circleBackgroundColor: Int
|
||||
get() = mCircleBackgroundColor
|
||||
set(@ColorInt circleBackgroundColor) {
|
||||
if (circleBackgroundColor == mCircleBackgroundColor) {
|
||||
return
|
||||
}
|
||||
mCircleBackgroundColor = circleBackgroundColor
|
||||
mCircleBackgroundPaint.color = circleBackgroundColor
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var borderWidth: Int
|
||||
get() = mBorderWidth
|
||||
set(borderWidth) {
|
||||
if (borderWidth == mBorderWidth) {
|
||||
return
|
||||
}
|
||||
|
||||
mBorderWidth = borderWidth
|
||||
setup()
|
||||
}
|
||||
|
||||
var isBorderOverlay: Boolean
|
||||
get() = mBorderOverlay
|
||||
set(borderOverlay) {
|
||||
if (borderOverlay == mBorderOverlay) {
|
||||
return
|
||||
}
|
||||
|
||||
mBorderOverlay = borderOverlay
|
||||
setup()
|
||||
}
|
||||
|
||||
private var text: String? = null
|
||||
|
||||
private var textColor = context.getCompatColor(R.color.primaryText)
|
||||
private var textBold = false
|
||||
var isInView = false
|
||||
|
||||
init {
|
||||
val a = context.obtainStyledAttributes(attrs, R.styleable.CircleImageView)
|
||||
mBorderWidth =
|
||||
a.getDimensionPixelSize(
|
||||
R.styleable.CircleImageView_civ_border_width,
|
||||
DEFAULT_BORDER_WIDTH
|
||||
)
|
||||
mBorderColor =
|
||||
a.getColor(R.styleable.CircleImageView_civ_border_color, DEFAULT_BORDER_COLOR)
|
||||
mBorderOverlay =
|
||||
a.getBoolean(R.styleable.CircleImageView_civ_border_overlay, DEFAULT_BORDER_OVERLAY)
|
||||
mCircleBackgroundColor =
|
||||
a.getColor(
|
||||
R.styleable.CircleImageView_civ_circle_background_color,
|
||||
DEFAULT_CIRCLE_BACKGROUND_COLOR
|
||||
)
|
||||
text = a.getString(R.styleable.CircleImageView_text)
|
||||
contentDescription = text
|
||||
if (a.hasValue(R.styleable.CircleImageView_textColor)) {
|
||||
textColor = a.getColor(
|
||||
R.styleable.CircleImageView_textColor,
|
||||
context.getCompatColor(R.color.primaryText)
|
||||
)
|
||||
}
|
||||
a.recycle()
|
||||
|
||||
mReady = true
|
||||
|
||||
if (mSetupPending) {
|
||||
setup()
|
||||
mSetupPending = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun setAdjustViewBounds(adjustViewBounds: Boolean) {
|
||||
if (adjustViewBounds) {
|
||||
throw IllegalArgumentException("adjustViewBounds not supported.")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (isDisableCircularTransformation) {
|
||||
super.onDraw(canvas)
|
||||
return
|
||||
}
|
||||
if (mBitmap == null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mCircleBackgroundColor != Color.TRANSPARENT) {
|
||||
canvas.drawCircle(
|
||||
mDrawableRect.centerX(),
|
||||
mDrawableRect.centerY(),
|
||||
mDrawableRadius,
|
||||
mCircleBackgroundPaint
|
||||
)
|
||||
}
|
||||
canvas.drawCircle(
|
||||
mDrawableRect.centerX(),
|
||||
mDrawableRect.centerY(),
|
||||
mDrawableRadius,
|
||||
mBitmapPaint
|
||||
)
|
||||
if (mBorderWidth > 0) {
|
||||
canvas.drawCircle(
|
||||
mBorderRect.centerX(),
|
||||
mBorderRect.centerY(),
|
||||
mBorderRadius,
|
||||
mBorderPaint
|
||||
)
|
||||
}
|
||||
drawText(canvas)
|
||||
}
|
||||
|
||||
private fun drawText(canvas: Canvas) {
|
||||
text?.let {
|
||||
textPaint.color = textColor
|
||||
textPaint.isFakeBoldText = textBold
|
||||
textPaint.textSize = 15f.spToPx()
|
||||
val fm = textPaint.fontMetrics
|
||||
canvas.drawText(
|
||||
it,
|
||||
width * 0.5f,
|
||||
(height * 0.5f + (fm.bottom - fm.top) * 0.5f - fm.bottom),
|
||||
textPaint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setText(text: String?) {
|
||||
this.text = text
|
||||
contentDescription = text
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setTextColor(@ColorInt textColor: Int) {
|
||||
this.textColor = textColor
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setTextBold(bold: Boolean) {
|
||||
this.textBold = bold
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
setup()
|
||||
}
|
||||
|
||||
override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.setPadding(left, top, right, bottom)
|
||||
setup()
|
||||
}
|
||||
|
||||
override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) {
|
||||
super.setPaddingRelative(start, top, end, bottom)
|
||||
setup()
|
||||
}
|
||||
|
||||
fun setCircleBackgroundColorResource(@ColorRes circleBackgroundRes: Int) {
|
||||
circleBackgroundColor = context.getCompatColor(circleBackgroundRes)
|
||||
}
|
||||
|
||||
override fun setImageBitmap(bm: Bitmap) {
|
||||
super.setImageBitmap(bm)
|
||||
initializeBitmap()
|
||||
}
|
||||
|
||||
override fun setImageDrawable(drawable: Drawable?) {
|
||||
super.setImageDrawable(drawable)
|
||||
initializeBitmap()
|
||||
}
|
||||
|
||||
override fun setImageResource(@DrawableRes resId: Int) {
|
||||
super.setImageResource(resId)
|
||||
initializeBitmap()
|
||||
}
|
||||
|
||||
override fun setImageURI(uri: Uri?) {
|
||||
super.setImageURI(uri)
|
||||
initializeBitmap()
|
||||
}
|
||||
|
||||
override fun setColorFilter(cf: ColorFilter) {
|
||||
if (cf === mColorFilter) {
|
||||
return
|
||||
}
|
||||
|
||||
mColorFilter = cf
|
||||
applyColorFilter()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun getColorFilter(): ColorFilter? {
|
||||
return mColorFilter
|
||||
}
|
||||
|
||||
private fun applyColorFilter() {
|
||||
mBitmapPaint.colorFilter = mColorFilter
|
||||
}
|
||||
|
||||
private fun getBitmapFromDrawable(drawable: Drawable?): Bitmap? {
|
||||
if (drawable == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (drawable is BitmapDrawable) {
|
||||
return drawable.bitmap
|
||||
}
|
||||
|
||||
return try {
|
||||
val bitmap: Bitmap = if (drawable is ColorDrawable) {
|
||||
Bitmap.createBitmap(
|
||||
COLOR_DRAWABLE_DIMENSION,
|
||||
COLOR_DRAWABLE_DIMENSION,
|
||||
BITMAP_CONFIG
|
||||
)
|
||||
} else {
|
||||
Bitmap.createBitmap(
|
||||
drawable.intrinsicWidth,
|
||||
drawable.intrinsicHeight,
|
||||
BITMAP_CONFIG
|
||||
)
|
||||
}
|
||||
|
||||
val canvas = Canvas(bitmap)
|
||||
drawable.setBounds(0, 0, canvas.width, canvas.height)
|
||||
drawable.draw(canvas)
|
||||
bitmap
|
||||
} catch (e: Exception) {
|
||||
e.printOnDebug()
|
||||
null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun initializeBitmap() {
|
||||
mBitmap = if (isDisableCircularTransformation) {
|
||||
null
|
||||
} else {
|
||||
getBitmapFromDrawable(drawable)
|
||||
}
|
||||
setup()
|
||||
}
|
||||
|
||||
private fun setup() {
|
||||
if (!mReady) {
|
||||
mSetupPending = true
|
||||
return
|
||||
}
|
||||
|
||||
if (width == 0 && height == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mBitmap == null) {
|
||||
invalidate()
|
||||
return
|
||||
}
|
||||
|
||||
mBitmapShader = BitmapShader(mBitmap!!, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
|
||||
|
||||
mBitmapPaint.isAntiAlias = true
|
||||
mBitmapPaint.shader = mBitmapShader
|
||||
|
||||
mBorderPaint.style = Paint.Style.STROKE
|
||||
mBorderPaint.isAntiAlias = true
|
||||
mBorderPaint.color = mBorderColor
|
||||
mBorderPaint.strokeWidth = mBorderWidth.toFloat()
|
||||
|
||||
mCircleBackgroundPaint.style = Paint.Style.FILL
|
||||
mCircleBackgroundPaint.isAntiAlias = true
|
||||
mCircleBackgroundPaint.color = mCircleBackgroundColor
|
||||
|
||||
mBitmapHeight = mBitmap!!.height
|
||||
mBitmapWidth = mBitmap!!.width
|
||||
|
||||
mBorderRect.set(calculateBounds())
|
||||
mBorderRadius =
|
||||
min(
|
||||
(mBorderRect.height() - mBorderWidth) / 2.0f,
|
||||
(mBorderRect.width() - mBorderWidth) / 2.0f
|
||||
)
|
||||
|
||||
mDrawableRect.set(mBorderRect)
|
||||
if (!mBorderOverlay && mBorderWidth > 0) {
|
||||
mDrawableRect.inset(mBorderWidth - 1.0f, mBorderWidth - 1.0f)
|
||||
}
|
||||
mDrawableRadius = min(mDrawableRect.height() / 2.0f, mDrawableRect.width() / 2.0f)
|
||||
|
||||
applyColorFilter()
|
||||
updateShaderMatrix()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun calculateBounds(): RectF {
|
||||
val availableWidth = width - paddingLeft - paddingRight
|
||||
val availableHeight = height - paddingTop - paddingBottom
|
||||
|
||||
val sideLength = min(availableWidth, availableHeight)
|
||||
|
||||
val left = paddingLeft + (availableWidth - sideLength) / 2f
|
||||
val top = paddingTop + (availableHeight - sideLength) / 2f
|
||||
|
||||
return RectF(left, top, left + sideLength, top + sideLength)
|
||||
}
|
||||
|
||||
private fun updateShaderMatrix() {
|
||||
val scale: Float
|
||||
var dx = 0f
|
||||
var dy = 0f
|
||||
|
||||
mShaderMatrix.set(null)
|
||||
|
||||
if (mBitmapWidth * mDrawableRect.height() > mDrawableRect.width() * mBitmapHeight) {
|
||||
scale = mDrawableRect.height() / mBitmapHeight.toFloat()
|
||||
dx = (mDrawableRect.width() - mBitmapWidth * scale) * 0.5f
|
||||
} else {
|
||||
scale = mDrawableRect.width() / mBitmapWidth.toFloat()
|
||||
dy = (mDrawableRect.height() - mBitmapHeight * scale) * 0.5f
|
||||
}
|
||||
|
||||
mShaderMatrix.setScale(scale, scale)
|
||||
mShaderMatrix.postTranslate(
|
||||
(dx + 0.5f).toInt() + mDrawableRect.left,
|
||||
(dy + 0.5f).toInt() + mDrawableRect.top
|
||||
)
|
||||
|
||||
mBitmapShader!!.setLocalMatrix(mShaderMatrix)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
isInView = (inTouchableArea(event.x, event.y))
|
||||
}
|
||||
}
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
private fun inTouchableArea(x: Float, y: Float): Boolean {
|
||||
return (x - mBorderRect.centerX()).toDouble()
|
||||
.pow(2.0) + (y - mBorderRect.centerY()).toDouble()
|
||||
.pow(2.0) <= mBorderRadius.toDouble().pow(2.0)
|
||||
}
|
||||
|
||||
private inner class OutlineProvider : ViewOutlineProvider() {
|
||||
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
val bounds = Rect()
|
||||
mBorderRect.roundOut(bounds)
|
||||
outline.setRoundRect(bounds, bounds.width() / 2.0f)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val BITMAP_CONFIG = Bitmap.Config.ARGB_8888
|
||||
private const val COLOR_DRAWABLE_DIMENSION = 2
|
||||
private const val DEFAULT_BORDER_WIDTH = 0
|
||||
private const val DEFAULT_BORDER_COLOR = Color.BLACK
|
||||
private const val DEFAULT_CIRCLE_BACKGROUND_COLOR = Color.TRANSPARENT
|
||||
private const val DEFAULT_BORDER_OVERLAY = false
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package io.legato.kazusa.ui.widget.image
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.text.TextPaint
|
||||
import android.util.AttributeSet
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.engine.GlideException
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import io.legato.kazusa.constant.AppPattern
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.help.glide.ImageLoader
|
||||
import io.legato.kazusa.help.glide.OkHttpModelLoader
|
||||
//import io.legado.app.lib.theme.accentColor
|
||||
import io.legato.kazusa.model.BookCover
|
||||
import io.legato.kazusa.utils.textHeight
|
||||
import io.legato.kazusa.utils.toStringArray
|
||||
|
||||
/**
|
||||
* 封面
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class CoverImageView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppCompatImageView(context, attrs) {
|
||||
private var filletPath = Path()
|
||||
private var viewWidth: Float = 0f
|
||||
private var viewHeight: Float = 0f
|
||||
private var defaultCover = true
|
||||
var bitmapPath: String? = null
|
||||
private set
|
||||
private var name: String? = null
|
||||
private var author: String? = null
|
||||
private var nameHeight = 0f
|
||||
private var authorHeight = 0f
|
||||
private val namePaint by lazy {
|
||||
val textPaint = TextPaint()
|
||||
textPaint.typeface = Typeface.DEFAULT_BOLD
|
||||
textPaint.isAntiAlias = true
|
||||
textPaint.textAlign = Paint.Align.CENTER
|
||||
textPaint
|
||||
}
|
||||
private val authorPaint by lazy {
|
||||
val textPaint = TextPaint()
|
||||
textPaint.typeface = Typeface.DEFAULT
|
||||
textPaint.isAntiAlias = true
|
||||
textPaint.textAlign = Paint.Align.CENTER
|
||||
textPaint
|
||||
}
|
||||
|
||||
override fun setLayoutParams(params: ViewGroup.LayoutParams?) {
|
||||
if (params != null) {
|
||||
val width = params.width
|
||||
if (width >= 0) {
|
||||
params.height = width * 7 / 5
|
||||
} else {
|
||||
params.height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
}
|
||||
super.setLayoutParams(params)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val measuredWidth = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val measuredHeight = measuredWidth * 7 / 5
|
||||
super.onMeasure(
|
||||
widthMeasureSpec,
|
||||
MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY)
|
||||
)
|
||||
}
|
||||
|
||||
private val cornerRadius = 24f
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
viewWidth = width.toFloat()
|
||||
viewHeight = height.toFloat()
|
||||
filletPath.reset()
|
||||
|
||||
if (viewWidth > cornerRadius * 2 && viewHeight > cornerRadius * 2) {
|
||||
val r = cornerRadius
|
||||
filletPath.apply {
|
||||
moveTo(r, 0f)
|
||||
lineTo(viewWidth - r, 0f)
|
||||
quadTo(viewWidth, 0f, viewWidth, r)
|
||||
lineTo(viewWidth, viewHeight - r)
|
||||
quadTo(viewWidth, viewHeight, viewWidth - r, viewHeight)
|
||||
lineTo(r, viewHeight)
|
||||
quadTo(0f, viewHeight, 0f, viewHeight - r)
|
||||
lineTo(0f, r)
|
||||
quadTo(0f, 0f, r, 0f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (!filletPath.isEmpty) {
|
||||
canvas.clipPath(filletPath)
|
||||
}
|
||||
super.onDraw(canvas)
|
||||
if (defaultCover && !isInEditMode) {
|
||||
drawNameAuthor(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawNameAuthor(canvas: Canvas) {
|
||||
if (!BookCover.drawBookName) return
|
||||
var startX = width * 0.2f
|
||||
var startY = viewHeight * 0.2f
|
||||
name?.toStringArray()?.let { name ->
|
||||
namePaint.textSize = viewWidth / 6
|
||||
namePaint.strokeWidth = namePaint.textSize / 5
|
||||
name.forEachIndexed { index, char ->
|
||||
namePaint.color = Color.WHITE
|
||||
namePaint.style = Paint.Style.STROKE
|
||||
canvas.drawText(char, startX, startY, namePaint)
|
||||
//namePaint.color = context.accentColor
|
||||
namePaint.style = Paint.Style.FILL
|
||||
canvas.drawText(char, startX, startY, namePaint)
|
||||
startY += namePaint.textHeight
|
||||
if (startY > viewHeight * 0.8) {
|
||||
startX += namePaint.textSize
|
||||
namePaint.textSize = viewWidth / 10
|
||||
startY = (viewHeight - (name.size - index - 1) * namePaint.textHeight) / 2
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!BookCover.drawBookAuthor) return
|
||||
author?.toStringArray()?.let { author ->
|
||||
authorPaint.textSize = viewWidth / 10
|
||||
authorPaint.strokeWidth = authorPaint.textSize / 5
|
||||
startX = width * 0.8f
|
||||
startY = viewHeight * 0.95f - author.size * authorPaint.textHeight
|
||||
startY = maxOf(startY, viewHeight * 0.3f)
|
||||
author.forEach {
|
||||
authorPaint.color = Color.WHITE
|
||||
authorPaint.style = Paint.Style.STROKE
|
||||
canvas.drawText(it, startX, startY, authorPaint)
|
||||
//authorPaint.color = context.accentColor
|
||||
authorPaint.style = Paint.Style.FILL
|
||||
canvas.drawText(it, startX, startY, authorPaint)
|
||||
startY += authorPaint.textHeight
|
||||
if (startY > viewHeight * 0.95) {
|
||||
return@let
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setHeight(height: Int) {
|
||||
val width = height * 5 / 7
|
||||
minimumWidth = width
|
||||
}
|
||||
|
||||
private val glideListener by lazy {
|
||||
object : RequestListener<Drawable> {
|
||||
|
||||
override fun onLoadFailed(
|
||||
e: GlideException?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
defaultCover = true
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
model: Any,
|
||||
target: Target<Drawable>?,
|
||||
dataSource: DataSource,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
defaultCover = false
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun load(
|
||||
path: String? = null,
|
||||
name: String? = null,
|
||||
author: String? = null,
|
||||
loadOnlyWifi: Boolean = false,
|
||||
sourceOrigin: String? = null,
|
||||
fragment: Fragment? = null,
|
||||
lifecycle: Lifecycle? = null,
|
||||
onLoadFinish: (() -> Unit)? = null
|
||||
) {
|
||||
this.bitmapPath = path
|
||||
this.name = name?.replace(AppPattern.bdRegex, "")?.trim()
|
||||
this.author = author?.replace(AppPattern.bdRegex, "")?.trim()
|
||||
defaultCover = true
|
||||
invalidate()
|
||||
if (AppConfig.useDefaultCover) {
|
||||
ImageLoader.load(context, BookCover.defaultDrawable)
|
||||
.centerCrop()
|
||||
.into(this)
|
||||
} else {
|
||||
var options = RequestOptions().set(OkHttpModelLoader.loadOnlyWifiOption, loadOnlyWifi)
|
||||
if (sourceOrigin != null) {
|
||||
options = options.set(OkHttpModelLoader.sourceOriginOption, sourceOrigin)
|
||||
}
|
||||
var builder = if (fragment != null && lifecycle != null) {
|
||||
ImageLoader.load(fragment, lifecycle, path)
|
||||
} else {
|
||||
ImageLoader.load(context, path)//Glide自动识别http://,content://和file://
|
||||
}
|
||||
builder = builder.apply(options)
|
||||
.placeholder(BookCover.defaultDrawable)
|
||||
.error(BookCover.defaultDrawable)
|
||||
.listener(glideListener)
|
||||
if (onLoadFinish != null) {
|
||||
builder = builder.addListener(object : RequestListener<Drawable> {
|
||||
override fun onLoadFailed(
|
||||
e: GlideException?,
|
||||
model: Any?,
|
||||
target: Target<Drawable?>,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
onLoadFinish.invoke()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
model: Any,
|
||||
target: Target<Drawable?>?,
|
||||
dataSource: DataSource,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
onLoadFinish.invoke()
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
builder
|
||||
.centerCrop()
|
||||
.into(this)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package io.legato.kazusa.ui.widget.image
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Path
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.utils.dpToPx
|
||||
import kotlin.math.max
|
||||
|
||||
class FilletImageView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppCompatImageView(context, attrs) {
|
||||
internal var width: Float = 0.toFloat()
|
||||
internal var height: Float = 0.toFloat()
|
||||
private var leftTopRadius: Int = 0
|
||||
private var rightTopRadius: Int = 0
|
||||
private var rightBottomRadius: Int = 0
|
||||
private var leftBottomRadius: Int = 0
|
||||
|
||||
init {
|
||||
// 读取配置
|
||||
val array = context.obtainStyledAttributes(attrs, R.styleable.FilletImageView)
|
||||
val defaultRadius = 5.dpToPx()
|
||||
val radius =
|
||||
array.getDimensionPixelOffset(R.styleable.FilletImageView_radius, defaultRadius)
|
||||
leftTopRadius = array.getDimensionPixelOffset(
|
||||
R.styleable.FilletImageView_left_top_radius,
|
||||
defaultRadius
|
||||
)
|
||||
rightTopRadius = array.getDimensionPixelOffset(
|
||||
R.styleable.FilletImageView_right_top_radius,
|
||||
defaultRadius
|
||||
)
|
||||
rightBottomRadius =
|
||||
array.getDimensionPixelOffset(
|
||||
R.styleable.FilletImageView_right_bottom_radius,
|
||||
defaultRadius
|
||||
)
|
||||
leftBottomRadius = array.getDimensionPixelOffset(
|
||||
R.styleable.FilletImageView_left_bottom_radius,
|
||||
defaultRadius
|
||||
)
|
||||
|
||||
//如果四个角的值没有设置,那么就使用通用的radius的值。
|
||||
if (defaultRadius == leftTopRadius) {
|
||||
leftTopRadius = radius
|
||||
}
|
||||
if (defaultRadius == rightTopRadius) {
|
||||
rightTopRadius = radius
|
||||
}
|
||||
if (defaultRadius == rightBottomRadius) {
|
||||
rightBottomRadius = radius
|
||||
}
|
||||
if (defaultRadius == leftBottomRadius) {
|
||||
leftBottomRadius = radius
|
||||
}
|
||||
array.recycle()
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
width = getWidth().toFloat()
|
||||
height = getHeight().toFloat()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
//这里做下判断,只有图片的宽高大于设置的圆角距离的时候才进行裁剪
|
||||
val maxLeft = max(leftTopRadius, leftBottomRadius)
|
||||
val maxRight = max(rightTopRadius, rightBottomRadius)
|
||||
val minWidth = maxLeft + maxRight
|
||||
val maxTop = max(leftTopRadius, rightTopRadius)
|
||||
val maxBottom = max(leftBottomRadius, rightBottomRadius)
|
||||
val minHeight = maxTop + maxBottom
|
||||
if (width >= minWidth && height > minHeight) {
|
||||
@SuppressLint("DrawAllocation") val path = Path()
|
||||
//四个角:右上,右下,左下,左上
|
||||
path.moveTo(leftTopRadius.toFloat(), 0f)
|
||||
path.lineTo(width - rightTopRadius, 0f)
|
||||
path.quadTo(width, 0f, width, rightTopRadius.toFloat())
|
||||
|
||||
path.lineTo(width, height - rightBottomRadius)
|
||||
path.quadTo(width, height, width - rightBottomRadius, height)
|
||||
|
||||
path.lineTo(leftBottomRadius.toFloat(), height)
|
||||
path.quadTo(0f, height, 0f, height - leftBottomRadius)
|
||||
|
||||
path.lineTo(0f, leftTopRadius.toFloat())
|
||||
path.quadTo(0f, 0f, leftTopRadius.toFloat(), 0f)
|
||||
|
||||
canvas.clipPath(path)
|
||||
}
|
||||
super.onDraw(canvas)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package io.legato.kazusa.ui.widget.image
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
|
||||
class ImageButton @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppCompatImageView(context, attrs) {
|
||||
|
||||
|
||||
override fun setEnabled(enabled: Boolean) {
|
||||
if (isEnabled != enabled) {
|
||||
imageAlpha = if (enabled) 0xFF else 0x3F
|
||||
}
|
||||
super.setEnabled(enabled)
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,49 @@
|
||||
package io.legato.kazusa.ui.widget.image.photo
|
||||
|
||||
import android.graphics.PointF
|
||||
|
||||
import android.graphics.RectF
|
||||
import android.widget.ImageView
|
||||
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
class Info(
|
||||
rect: RectF,
|
||||
img: RectF,
|
||||
widget: RectF,
|
||||
base: RectF,
|
||||
screenCenter: PointF,
|
||||
scale: Float,
|
||||
degrees: Float,
|
||||
scaleType: ImageView.ScaleType?
|
||||
) {
|
||||
// 内部图片在整个手机界面的位置
|
||||
var mRect = RectF()
|
||||
|
||||
// 控件在窗口的位置
|
||||
var mImgRect = RectF()
|
||||
|
||||
var mWidgetRect = RectF()
|
||||
|
||||
var mBaseRect = RectF()
|
||||
|
||||
var mScreenCenter = PointF()
|
||||
|
||||
var mScale = 0f
|
||||
|
||||
var mDegrees = 0f
|
||||
|
||||
var mScaleType: ImageView.ScaleType? = null
|
||||
|
||||
init {
|
||||
mRect.set(rect)
|
||||
mImgRect.set(img)
|
||||
mWidgetRect.set(widget)
|
||||
mScale = scale
|
||||
mScaleType = scaleType
|
||||
mDegrees = degrees
|
||||
mBaseRect.set(base)
|
||||
mScreenCenter.set(screenCenter)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package io.legato.kazusa.ui.widget.image.photo
|
||||
|
||||
import android.view.MotionEvent
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan
|
||||
|
||||
class RotateGestureDetector(private val mListener: OnRotateListener) {
|
||||
|
||||
private val MAX_DEGREES_STEP = 120
|
||||
|
||||
private var mPrevSlope = 0f
|
||||
private var mCurrSlope = 0f
|
||||
|
||||
private val x1 = 0f
|
||||
private val y1 = 0f
|
||||
private val x2 = 0f
|
||||
private val y2 = 0f
|
||||
|
||||
fun onTouchEvent(event: MotionEvent) {
|
||||
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_POINTER_DOWN,
|
||||
MotionEvent.ACTION_POINTER_UP -> {
|
||||
if (event.pointerCount == 2) mPrevSlope = calculateSlope(event)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1) {
|
||||
mCurrSlope = calculateSlope(event)
|
||||
|
||||
val currDegrees = Math.toDegrees(atan(mCurrSlope.toDouble()))
|
||||
val prevDegrees = Math.toDegrees(atan(mPrevSlope.toDouble()))
|
||||
|
||||
val deltaSlope = currDegrees - prevDegrees
|
||||
|
||||
if (abs(deltaSlope) <= MAX_DEGREES_STEP) {
|
||||
mListener.onRotate(deltaSlope.toFloat(), (x2 + x1) / 2, (y2 + y1) / 2)
|
||||
}
|
||||
mPrevSlope = mCurrSlope
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun calculateSlope(event: MotionEvent): Float {
|
||||
val x1 = event.getX(0)
|
||||
val y1 = event.getY(0)
|
||||
val x2 = event.getX(1)
|
||||
val y2 = event.getY(1)
|
||||
return (y2 - y1) / (x2 - x1)
|
||||
}
|
||||
}
|
||||
|
||||
interface OnRotateListener {
|
||||
fun onRotate(degrees: Float, focusX: Float, focusY: Float)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package io.legato.kazusa.ui.widget.keyboard
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.setPadding
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.base.BaseDialogFragment
|
||||
import io.legato.kazusa.base.adapter.ItemViewHolder
|
||||
import io.legato.kazusa.base.adapter.RecyclerAdapter
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.KeyboardAssist
|
||||
import io.legato.kazusa.databinding.DialogMultipleEditTextBinding
|
||||
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
|
||||
import io.legato.kazusa.databinding.Item1lineTextAndDelBinding
|
||||
import io.legato.kazusa.lib.dialogs.alert
|
||||
//import io.legado.app.lib.theme.backgroundColor
|
||||
//import io.legado.app.lib.theme.primaryColor
|
||||
import io.legato.kazusa.ui.widget.recycler.ItemTouchCallback
|
||||
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
|
||||
import io.legato.kazusa.utils.applyTint
|
||||
import io.legato.kazusa.utils.dpToPx
|
||||
import io.legato.kazusa.utils.setLayout
|
||||
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
|
||||
import io.legato.kazusa.utils.visible
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 辅助按键配置
|
||||
*/
|
||||
class KeyboardAssistsConfig : BaseDialogFragment(R.layout.dialog_recycler_view),
|
||||
Toolbar.OnMenuItemClickListener {
|
||||
|
||||
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
|
||||
private val adapter by lazy { KeyAdapter(requireContext()) }
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
setLayout(0.9f, 0.9f)
|
||||
}
|
||||
|
||||
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
|
||||
//binding.toolBar.setBackgroundColor(primaryColor)
|
||||
binding.toolBar.setTitle(R.string.assists_key_config)
|
||||
initView()
|
||||
initMenu()
|
||||
initData()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
|
||||
binding.recyclerView.adapter = adapter
|
||||
val itemTouchCallback = ItemTouchCallback(adapter)
|
||||
itemTouchCallback.isCanDrag = true
|
||||
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
|
||||
|
||||
}
|
||||
|
||||
private fun initMenu() {
|
||||
binding.toolBar.setOnMenuItemClickListener(this)
|
||||
binding.toolBar.inflateMenu(R.menu.keyboard_assists_config)
|
||||
binding.toolBar.menu.applyTint(requireContext())
|
||||
}
|
||||
|
||||
private fun initData() {
|
||||
lifecycleScope.launch {
|
||||
appDb.keyboardAssistsDao.flowAll.catch {
|
||||
AppLog.put("辅助按键配置获取数据失败\n${it.localizedMessage}", it)
|
||||
}.flowOn(IO).collect {
|
||||
adapter.setItems(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem?): Boolean {
|
||||
when (item?.itemId) {
|
||||
R.id.menu_add -> editKey(null)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun editKey(keyboardAssist: KeyboardAssist?) {
|
||||
alert {
|
||||
setTitle("辅助按键")
|
||||
val alertBinding = DialogMultipleEditTextBinding.inflate(layoutInflater).apply {
|
||||
layout1.hint = "key"
|
||||
edit1.setText(keyboardAssist?.key)
|
||||
layout2.hint = "value"
|
||||
layout2.visible()
|
||||
edit2.setText(keyboardAssist?.value)
|
||||
}
|
||||
setCustomView(alertBinding.root)
|
||||
cancelButton()
|
||||
okButton {
|
||||
lifecycleScope.launch(IO) {
|
||||
val newKeyboardAssist = KeyboardAssist(
|
||||
key = alertBinding.edit1.text.toString(),
|
||||
value = alertBinding.edit2.text.toString()
|
||||
)
|
||||
if (keyboardAssist == null) {
|
||||
newKeyboardAssist.serialNo = appDb.keyboardAssistsDao.maxSerialNo + 1
|
||||
appDb.keyboardAssistsDao.insert(newKeyboardAssist)
|
||||
} else {
|
||||
newKeyboardAssist.serialNo = keyboardAssist.serialNo
|
||||
appDb.keyboardAssistsDao.delete(keyboardAssist)
|
||||
appDb.keyboardAssistsDao.insert(newKeyboardAssist)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class KeyAdapter(context: Context) :
|
||||
RecyclerAdapter<KeyboardAssist, Item1lineTextAndDelBinding>(context),
|
||||
ItemTouchCallback.Callback {
|
||||
|
||||
private var isMoved = false
|
||||
|
||||
override fun getViewBinding(parent: ViewGroup): Item1lineTextAndDelBinding {
|
||||
return Item1lineTextAndDelBinding.inflate(inflater, parent, false).apply {
|
||||
root.setPadding(16.dpToPx())
|
||||
ivDelete.visible()
|
||||
}
|
||||
}
|
||||
|
||||
override fun convert(
|
||||
holder: ItemViewHolder,
|
||||
binding: Item1lineTextAndDelBinding,
|
||||
item: KeyboardAssist,
|
||||
payloads: MutableList<Any>
|
||||
) {
|
||||
//binding.root.setBackgroundColor(context.backgroundColor)
|
||||
binding.textView.text = item.key
|
||||
}
|
||||
|
||||
override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextAndDelBinding) {
|
||||
binding.root.setOnClickListener {
|
||||
getItemByLayoutPosition(holder.layoutPosition)?.let { keyboardAssist ->
|
||||
editKey(keyboardAssist)
|
||||
}
|
||||
}
|
||||
binding.ivDelete.setOnClickListener {
|
||||
getItemByLayoutPosition(holder.layoutPosition)?.let { keyboardAssist ->
|
||||
lifecycleScope.launch(IO) {
|
||||
appDb.keyboardAssistsDao.delete(keyboardAssist)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
|
||||
swapItem(srcPosition, targetPosition)
|
||||
isMoved = true
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||
if (isMoved) {
|
||||
for ((index, item) in getItems().withIndex()) {
|
||||
item.serialNo = index + 1
|
||||
}
|
||||
lifecycleScope.launch(IO) {
|
||||
appDb.keyboardAssistsDao.update(*getItems().toTypedArray())
|
||||
}
|
||||
}
|
||||
isMoved = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package io.legato.kazusa.ui.widget.keyboard
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.Window
|
||||
import android.widget.PopupWindow
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.base.adapter.ItemViewHolder
|
||||
import io.legato.kazusa.base.adapter.RecyclerAdapter
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.KeyboardAssist
|
||||
import io.legato.kazusa.databinding.ItemFilletTextBinding
|
||||
import io.legato.kazusa.databinding.PopupKeyboardToolBinding
|
||||
import io.legato.kazusa.lib.dialogs.SelectItem
|
||||
import io.legato.kazusa.lib.dialogs.selector
|
||||
import io.legato.kazusa.utils.activity
|
||||
import io.legato.kazusa.utils.showDialogFragment
|
||||
import io.legato.kazusa.utils.windowSize
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import splitties.systemservices.layoutInflater
|
||||
import splitties.systemservices.windowManager
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* 键盘帮助浮窗
|
||||
*/
|
||||
class KeyboardToolPop(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val rootView: View,
|
||||
private val callBack: CallBack
|
||||
) : PopupWindow(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT),
|
||||
ViewTreeObserver.OnGlobalLayoutListener {
|
||||
|
||||
private val helpChar = "❓"
|
||||
|
||||
private val binding = PopupKeyboardToolBinding.inflate(LayoutInflater.from(context))
|
||||
private val adapter = Adapter(context)
|
||||
private var mIsSoftKeyBoardShowing = false
|
||||
var initialPadding = 0
|
||||
|
||||
init {
|
||||
contentView = binding.root
|
||||
|
||||
isTouchable = true
|
||||
isOutsideTouchable = false
|
||||
isFocusable = false
|
||||
inputMethodMode = INPUT_METHOD_NEEDED //解决遮盖输入法
|
||||
initRecyclerView()
|
||||
upAdapterData()
|
||||
}
|
||||
|
||||
fun attachToWindow(window: Window) {
|
||||
window.decorView.viewTreeObserver.addOnGlobalLayoutListener(this)
|
||||
contentView.measure(
|
||||
View.MeasureSpec.UNSPECIFIED,
|
||||
View.MeasureSpec.UNSPECIFIED,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onGlobalLayout() {
|
||||
val rect = Rect()
|
||||
// 获取当前页面窗口的显示范围
|
||||
rootView.getWindowVisibleDisplayFrame(rect)
|
||||
val screenHeight = windowManager.windowSize.heightPixels
|
||||
val keyboardHeight = screenHeight - rect.bottom // 输入法的高度
|
||||
val preShowing = mIsSoftKeyBoardShowing
|
||||
if (abs(keyboardHeight) > screenHeight / 5) {
|
||||
mIsSoftKeyBoardShowing = true // 超过屏幕五分之一则表示弹出了输入法
|
||||
rootView.setPadding(0, 0, 0, initialPadding + contentView.measuredHeight)
|
||||
if (!isShowing) {
|
||||
showAtLocation(rootView, Gravity.BOTTOM, 0, 0)
|
||||
}
|
||||
} else {
|
||||
mIsSoftKeyBoardShowing = false
|
||||
rootView.setPadding(0, 0, 0, 0)
|
||||
if (preShowing) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initRecyclerView() {
|
||||
binding.recyclerView.adapter = adapter
|
||||
adapter.addHeaderView {
|
||||
ItemFilletTextBinding.inflate(context.layoutInflater, it, false).apply {
|
||||
textView.text = helpChar
|
||||
root.setOnClickListener {
|
||||
helpAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
fun upAdapterData() {
|
||||
scope.launch {
|
||||
appDb.keyboardAssistsDao.flowByType(0).catch {
|
||||
AppLog.put("键盘帮助浮窗获取数据失败\n${it.localizedMessage}", it)
|
||||
}.flowOn(IO).collect {
|
||||
adapter.setItems(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun helpAlert() {
|
||||
val items = arrayListOf(
|
||||
SelectItem(context.getString(R.string.assists_key_config), "keyConfig")
|
||||
)
|
||||
items.addAll(callBack.helpActions())
|
||||
context.selector(context.getString(R.string.help), items) { _, selectItem, _ ->
|
||||
when (selectItem.value) {
|
||||
"keyConfig" -> config()
|
||||
else -> callBack.onHelpActionSelect(selectItem.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun config() {
|
||||
contentView.activity?.showDialogFragment<KeyboardAssistsConfig>()
|
||||
}
|
||||
|
||||
inner class Adapter(context: Context) :
|
||||
RecyclerAdapter<KeyboardAssist, ItemFilletTextBinding>(context) {
|
||||
|
||||
override fun getViewBinding(parent: ViewGroup): ItemFilletTextBinding {
|
||||
return ItemFilletTextBinding.inflate(inflater, parent, false)
|
||||
}
|
||||
|
||||
override fun convert(
|
||||
holder: ItemViewHolder,
|
||||
binding: ItemFilletTextBinding,
|
||||
item: KeyboardAssist,
|
||||
payloads: MutableList<Any>
|
||||
) {
|
||||
binding.run {
|
||||
textView.text = item.key
|
||||
}
|
||||
}
|
||||
|
||||
override fun registerListener(holder: ItemViewHolder, binding: ItemFilletTextBinding) {
|
||||
holder.itemView.apply {
|
||||
setOnClickListener {
|
||||
getItemByLayoutPosition(holder.layoutPosition)?.let {
|
||||
callBack.sendText(it.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CallBack {
|
||||
|
||||
fun helpActions(): List<SelectItem<String>> = arrayListOf()
|
||||
|
||||
fun onHelpActionSelect(action: String)
|
||||
|
||||
fun sendText(text: String)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package io.legato.kazusa.ui.widget.number
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.NumberPicker
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.utils.hideSoftInput
|
||||
|
||||
|
||||
class NumberPickerDialog(context: Context) {
|
||||
private val builder = MaterialAlertDialogBuilder(context)
|
||||
private var numberPicker: NumberPicker? = null
|
||||
private var maxValue: Int? = null
|
||||
private var minValue: Int? = null
|
||||
private var value: Int? = null
|
||||
|
||||
init {
|
||||
builder.setView(R.layout.dialog_number_picker)
|
||||
}
|
||||
|
||||
fun setTitle(title: String): NumberPickerDialog {
|
||||
builder.setTitle(title)
|
||||
return this
|
||||
}
|
||||
|
||||
fun setMaxValue(value: Int): NumberPickerDialog {
|
||||
maxValue = value
|
||||
return this
|
||||
}
|
||||
|
||||
fun setMinValue(value: Int): NumberPickerDialog {
|
||||
minValue = value
|
||||
return this
|
||||
}
|
||||
|
||||
fun setValue(value: Int): NumberPickerDialog {
|
||||
this.value = value
|
||||
return this
|
||||
}
|
||||
|
||||
fun setCustomButton(textId: Int, listener: (() -> Unit)?): NumberPickerDialog {
|
||||
builder.setNeutralButton(textId) { _, _ ->
|
||||
numberPicker?.let {
|
||||
it.clearFocus()
|
||||
it.hideSoftInput()
|
||||
listener?.invoke()
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun show(callBack: ((value: Int) -> Unit)?) {
|
||||
builder.setPositiveButton(R.string.ok) { _, _ ->
|
||||
numberPicker?.let {
|
||||
it.clearFocus()
|
||||
it.hideSoftInput()
|
||||
callBack?.invoke(it.value)
|
||||
}
|
||||
}
|
||||
builder.setNegativeButton(R.string.cancel, null)
|
||||
val dialog = builder.show()
|
||||
numberPicker = dialog.findViewById(R.id.number_picker)
|
||||
numberPicker?.let { np ->
|
||||
minValue?.let {
|
||||
np.minValue = it
|
||||
}
|
||||
maxValue?.let {
|
||||
np.maxValue = it
|
||||
}
|
||||
value?.let {
|
||||
np.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package io.legato.kazusa.ui.widget.recycler
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.legato.kazusa.utils.DebugLog
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
/**
|
||||
* 不画最后一条分隔线
|
||||
*/
|
||||
@Suppress("MemberVisibilityCanBePrivate", "RedundantRequireNotNullCall", "unused")
|
||||
class DividerNoLast(context: Context, orientation: Int) :
|
||||
RecyclerView.ItemDecoration() {
|
||||
|
||||
companion object {
|
||||
const val HORIZONTAL = LinearLayout.HORIZONTAL
|
||||
const val VERTICAL = LinearLayout.VERTICAL
|
||||
}
|
||||
|
||||
private val attrs = intArrayOf(android.R.attr.listDivider)
|
||||
|
||||
private var mDivider: Drawable? = null
|
||||
|
||||
/**
|
||||
* Current orientation. Either [.HORIZONTAL] or [.VERTICAL].
|
||||
*/
|
||||
private var mOrientation = 0
|
||||
|
||||
private val mBounds = Rect()
|
||||
|
||||
init {
|
||||
val a = context.obtainStyledAttributes(attrs)
|
||||
mDivider = a.getDrawable(0)
|
||||
if (mDivider == null) {
|
||||
DebugLog.w(
|
||||
javaClass.name,
|
||||
"@android:attr/listDivider was not set in the theme used for this DividerItemDecoration. Please set that attribute all call setDrawable()"
|
||||
)
|
||||
}
|
||||
a.recycle()
|
||||
setOrientation(orientation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the orientation for this divider. This should be called if
|
||||
* [RecyclerView.LayoutManager] changes orientation.
|
||||
*
|
||||
* @param orientation [.HORIZONTAL] or [.VERTICAL]
|
||||
*/
|
||||
fun setOrientation(orientation: Int) {
|
||||
require(!(orientation != HORIZONTAL && orientation != VERTICAL)) { "Invalid orientation. It should be either HORIZONTAL or VERTICAL" }
|
||||
mOrientation = orientation
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the [Drawable] for this divider.
|
||||
*
|
||||
* @param drawable Drawable that should be used as a divider.
|
||||
*/
|
||||
fun setDrawable(drawable: Drawable) {
|
||||
requireNotNull(drawable) { "Drawable cannot be null." }
|
||||
mDivider = drawable
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the [Drawable] for this divider.
|
||||
*/
|
||||
fun getDrawable(): Drawable? {
|
||||
return mDivider
|
||||
}
|
||||
|
||||
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
if (parent.layoutManager == null || mDivider == null) {
|
||||
return
|
||||
}
|
||||
if (mOrientation == VERTICAL) {
|
||||
drawVertical(c, parent)
|
||||
} else {
|
||||
drawHorizontal(c, parent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawVertical(
|
||||
canvas: Canvas,
|
||||
parent: RecyclerView
|
||||
) {
|
||||
canvas.save()
|
||||
val left: Int
|
||||
val right: Int
|
||||
if (parent.clipToPadding) {
|
||||
left = parent.paddingLeft
|
||||
right = parent.width - parent.paddingRight
|
||||
canvas.clipRect(
|
||||
left, parent.paddingTop, right,
|
||||
parent.height - parent.paddingBottom
|
||||
)
|
||||
} else {
|
||||
left = 0
|
||||
right = parent.width
|
||||
}
|
||||
val childCount = parent.childCount
|
||||
for (i in 0 until childCount - 1) {
|
||||
val child = parent.getChildAt(i)
|
||||
parent.getDecoratedBoundsWithMargins(child, mBounds)
|
||||
val bottom = mBounds.bottom + child.translationY.roundToInt()
|
||||
val top = bottom - mDivider!!.intrinsicHeight
|
||||
mDivider!!.setBounds(left, top, right, bottom)
|
||||
mDivider!!.draw(canvas)
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
private fun drawHorizontal(canvas: Canvas, parent: RecyclerView) {
|
||||
canvas.save()
|
||||
val top: Int
|
||||
val bottom: Int
|
||||
if (parent.clipToPadding) {
|
||||
top = parent.paddingTop
|
||||
bottom = parent.height - parent.paddingBottom
|
||||
canvas.clipRect(
|
||||
parent.paddingLeft, top,
|
||||
parent.width - parent.paddingRight, bottom
|
||||
)
|
||||
} else {
|
||||
top = 0
|
||||
bottom = parent.height
|
||||
}
|
||||
val childCount = parent.childCount
|
||||
for (i in 0 until childCount - 1) {
|
||||
val child = parent.getChildAt(i)
|
||||
parent.layoutManager!!.getDecoratedBoundsWithMargins(child, mBounds)
|
||||
val right = mBounds.right + child.translationX.roundToInt()
|
||||
val left = right - mDivider!!.intrinsicWidth
|
||||
mDivider!!.setBounds(left, top, right, bottom)
|
||||
mDivider!!.draw(canvas)
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect, view: View, parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
if (mDivider == null) {
|
||||
outRect[0, 0, 0] = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (mOrientation == VERTICAL) {
|
||||
outRect[0, 0, 0] = mDivider!!.intrinsicHeight
|
||||
} else {
|
||||
val childAdapterPosition = parent.getChildAdapterPosition(view)
|
||||
val lastCount = parent.adapter!!.itemCount - 1
|
||||
//如果不是最后一条 正常赋值 如果是最后一条 赋值为0
|
||||
if (childAdapterPosition != lastCount) {
|
||||
outRect[0, 0, mDivider!!.intrinsicWidth] = 0
|
||||
} else {
|
||||
outRect[0, 0, 0] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,1000 @@
|
||||
/*
|
||||
* Copyright 2020 Mupceet
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.legato.kazusa.ui.widget.recycler
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.text.TextUtils
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.TypedValue
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener
|
||||
import io.legato.kazusa.BuildConfig
|
||||
import io.legato.kazusa.utils.DebugLog
|
||||
import java.util.Locale
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* @author mupceet
|
||||
* !autoChangeMode +-------------------+ inactiveSelect()
|
||||
* +------------------------------------> | | <--------------------+
|
||||
* | | Normal | |
|
||||
* | activeDragSelect(position) | | activeSlideSelect() |
|
||||
* | +------------------------------ | | ----------+ |
|
||||
* | v +-------------------+ v |
|
||||
* +-------------------+ autoChangeMode +-----------------------+
|
||||
* | Drag From Disable | ----------------------------------------------> | |
|
||||
* +-------------------+ | |
|
||||
* | | | |
|
||||
* | | activeDragSelect(position) && allowDragInSlide | Slide |
|
||||
* | | <---------------------------------------------- | |
|
||||
* | Drag From Slide | | |
|
||||
* | | | |
|
||||
* | | ----------------------------------------------> | |
|
||||
* +-------------------+ +-----------------------+
|
||||
*/
|
||||
@Suppress("unused", "MemberVisibilityCanBePrivate", "DEPRECATION")
|
||||
class DragSelectTouchHelper(
|
||||
/**
|
||||
* Developer callback which controls the behavior of DragSelectTouchHelper.
|
||||
*/
|
||||
private val mCallback: Callback,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DSTH"
|
||||
private const val MAX_HOTSPOT_RATIO = 0.5f
|
||||
private val DEFAULT_EDGE_TYPE = EdgeType.INSIDE_EXTEND
|
||||
private const val DEFAULT_HOTSPOT_RATIO = 0.2f
|
||||
private const val DEFAULT_HOTSPOT_OFFSET = 0
|
||||
private const val DEFAULT_MAX_SCROLL_VELOCITY = 10
|
||||
private const val SELECT_STATE_NORMAL = 0x00
|
||||
private const val SELECT_STATE_SLIDE = 0x01
|
||||
private const val SELECT_STATE_DRAG_FROM_NORMAL = 0x10
|
||||
private const val SELECT_STATE_DRAG_FROM_SLIDE = 0x11
|
||||
}
|
||||
|
||||
private val mDisplayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics
|
||||
|
||||
/**
|
||||
* Start of the slide area.
|
||||
*/
|
||||
private var mSlideAreaLeft = 0f
|
||||
|
||||
/**
|
||||
* End of the slide area.
|
||||
*/
|
||||
private var mSlideAreaRight = 0f
|
||||
|
||||
/**
|
||||
* The hotspot height by the ratio of RecyclerView.
|
||||
*/
|
||||
private var mHotspotHeightRatio = 0f
|
||||
|
||||
/**
|
||||
* The hotspot height.
|
||||
*/
|
||||
private var mHotspotHeight = 0f
|
||||
|
||||
/**
|
||||
* The hotspot offset.
|
||||
*/
|
||||
private var mHotspotOffset = 0f
|
||||
|
||||
/**
|
||||
* Whether should continue scrolling when move outside top hotspot region.
|
||||
*/
|
||||
private var mScrollAboveTopRegion = false
|
||||
|
||||
/**
|
||||
* Whether should continue scrolling when move outside bottom hotspot region.
|
||||
*/
|
||||
private var mScrollBelowBottomRegion = false
|
||||
|
||||
/**
|
||||
* The maximum velocity of auto scrolling.
|
||||
*/
|
||||
private var mMaximumVelocity = 0
|
||||
|
||||
/**
|
||||
* Whether should auto enter slide mode after drag select finished.
|
||||
*/
|
||||
private var mShouldAutoChangeState = false
|
||||
|
||||
/**
|
||||
* Whether can drag selection in slide select mode.
|
||||
*/
|
||||
private var mIsAllowDragInSlideState = false
|
||||
private var mRecyclerView: RecyclerView? = null
|
||||
|
||||
/**
|
||||
* The coordinate of hotspot area.
|
||||
*/
|
||||
private var mTopRegionFrom = -1f
|
||||
private var mTopRegionTo = -1f
|
||||
private var mBottomRegionFrom = -1f
|
||||
private var mBottomRegionTo = -1f
|
||||
private val mOnLayoutChangeListener =
|
||||
View.OnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
|
||||
if (oldLeft != left || oldRight != right || oldTop != top || oldBottom != bottom) {
|
||||
if (v === mRecyclerView) {
|
||||
Logger.i(
|
||||
"onLayoutChange:new: "
|
||||
+ left + " " + top + " " + right + " " + bottom
|
||||
)
|
||||
Logger.i(
|
||||
"onLayoutChange:old: "
|
||||
+ oldLeft + " " + oldTop + " " + oldRight + " " + oldBottom
|
||||
)
|
||||
init(bottom - top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The current mode of selection.
|
||||
*/
|
||||
private var mSelectState = SELECT_STATE_NORMAL
|
||||
|
||||
/**
|
||||
* Whether is in top hotspot area.
|
||||
*/
|
||||
private var mIsInTopHotspot = false
|
||||
|
||||
/**
|
||||
* Whether is in bottom hotspot area.
|
||||
*/
|
||||
private var mIsInBottomHotspot = false
|
||||
|
||||
/**
|
||||
* Indicates automatically scroll.
|
||||
*/
|
||||
private var mIsScrolling = false
|
||||
|
||||
/**
|
||||
* The actual speed of the current moment.
|
||||
*/
|
||||
private var mScrollDistance = 0
|
||||
|
||||
/**
|
||||
* The reference coordinate for the action start, used to avoid reverse scrolling.
|
||||
*/
|
||||
private var mDownY = Float.MIN_VALUE
|
||||
|
||||
/**
|
||||
* The reference coordinates for the last action.
|
||||
*/
|
||||
private var mLastX = Float.MIN_VALUE
|
||||
private var mLastY = Float.MIN_VALUE
|
||||
|
||||
/**
|
||||
* The selected items position.
|
||||
*/
|
||||
private var mStart = RecyclerView.NO_POSITION
|
||||
private var mEnd = RecyclerView.NO_POSITION
|
||||
private var mLastRealStart = RecyclerView.NO_POSITION
|
||||
private var mLastRealEnd = RecyclerView.NO_POSITION
|
||||
private var mSlideStateStartPosition = RecyclerView.NO_POSITION
|
||||
private var mHaveCalledSelectStart = false
|
||||
private val mScrollRunnable: Runnable by lazy {
|
||||
object : Runnable {
|
||||
override fun run() {
|
||||
if (mIsScrolling) {
|
||||
scrollBy(mScrollDistance)
|
||||
ViewCompat.postOnAnimation(mRecyclerView!!, this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private val mOnItemTouchListener: OnItemTouchListener by lazy {
|
||||
object : OnItemTouchListener {
|
||||
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
|
||||
Logger.d(
|
||||
"onInterceptTouchEvent: x:" + e.x + ",y:" + e.y
|
||||
+ ", " + MotionEvent.actionToString(e.action)
|
||||
)
|
||||
val adapter = rv.adapter
|
||||
if (adapter == null || adapter.itemCount == 0) {
|
||||
return false
|
||||
}
|
||||
var intercept = false
|
||||
val action = e.action
|
||||
when (action and MotionEvent.ACTION_MASK) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
mDownY = e.y
|
||||
// call the selection start's callback before moving
|
||||
if (mSelectState == SELECT_STATE_SLIDE && isInSlideArea(e)) {
|
||||
mSlideStateStartPosition = getItemPosition(rv, e)
|
||||
if (mSlideStateStartPosition != RecyclerView.NO_POSITION) {
|
||||
mCallback.onSelectStart(mSlideStateStartPosition)
|
||||
mHaveCalledSelectStart = true
|
||||
}
|
||||
intercept = true
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> if (mSelectState == SELECT_STATE_DRAG_FROM_NORMAL
|
||||
|| mSelectState == SELECT_STATE_DRAG_FROM_SLIDE
|
||||
) {
|
||||
Logger.i("onInterceptTouchEvent: drag mode move")
|
||||
intercept = true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (mSelectState == SELECT_STATE_DRAG_FROM_NORMAL
|
||||
|| mSelectState == SELECT_STATE_DRAG_FROM_SLIDE
|
||||
) {
|
||||
intercept = true
|
||||
}
|
||||
// finger is lifted before moving
|
||||
if (mSlideStateStartPosition != RecyclerView.NO_POSITION) {
|
||||
selectFinished(mSlideStateStartPosition)
|
||||
mSlideStateStartPosition = RecyclerView.NO_POSITION
|
||||
}
|
||||
// selection has triggered
|
||||
if (mStart != RecyclerView.NO_POSITION) {
|
||||
selectFinished(mEnd)
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
if (mSlideStateStartPosition != RecyclerView.NO_POSITION) {
|
||||
selectFinished(mSlideStateStartPosition)
|
||||
mSlideStateStartPosition = RecyclerView.NO_POSITION
|
||||
}
|
||||
if (mStart != RecyclerView.NO_POSITION) {
|
||||
selectFinished(mEnd)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
// Intercept only when the selection is triggered
|
||||
Logger.d("intercept result: $intercept")
|
||||
return intercept
|
||||
}
|
||||
|
||||
override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {
|
||||
if (!isActivated) {
|
||||
return
|
||||
}
|
||||
Logger.d(
|
||||
"onTouchEvent: x:" + e.x + ",y:" + e.y
|
||||
+ ", " + MotionEvent.actionToString(e.action)
|
||||
)
|
||||
val action = e.action
|
||||
when (action and MotionEvent.ACTION_MASK) {
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (mSlideStateStartPosition != RecyclerView.NO_POSITION) {
|
||||
selectFirstItem(mSlideStateStartPosition)
|
||||
// selection is triggered
|
||||
mSlideStateStartPosition = RecyclerView.NO_POSITION
|
||||
Logger.i("onTouchEvent: after slide mode down")
|
||||
}
|
||||
processAutoScroll(e)
|
||||
if (!mIsInTopHotspot && !mIsInBottomHotspot) {
|
||||
updateSelectedRange(rv, e)
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
|
||||
if (mSlideStateStartPosition != RecyclerView.NO_POSITION) {
|
||||
selectFirstItem(mSlideStateStartPosition)
|
||||
// selection is triggered
|
||||
mSlideStateStartPosition = RecyclerView.NO_POSITION
|
||||
Logger.i("onTouchEvent: after slide mode down")
|
||||
}
|
||||
if (!mIsInTopHotspot && !mIsInBottomHotspot) {
|
||||
updateSelectedRange(rv, e)
|
||||
}
|
||||
selectFinished(mEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
|
||||
if (disallowIntercept) {
|
||||
inactiveSelect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
setHotspotRatio(DEFAULT_HOTSPOT_RATIO)
|
||||
setHotspotOffset(DEFAULT_HOTSPOT_OFFSET)
|
||||
setMaximumVelocity(DEFAULT_MAX_SCROLL_VELOCITY)
|
||||
setEdgeType(DEFAULT_EDGE_TYPE)
|
||||
setAutoEnterSlideState(false)
|
||||
setAllowDragInSlideState(false)
|
||||
setSlideArea(0, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches the DragSelectTouchHelper to the provided RecyclerView. If TouchHelper is already
|
||||
* attached to a RecyclerView, it will first detach from the previous one. You can call this
|
||||
* method with `null` to detach it from the current RecyclerView.
|
||||
*
|
||||
* @param recyclerView The RecyclerView instance to which you want to add this helper or
|
||||
* `null` if you want to remove DragSelectTouchHelper from the
|
||||
* current RecyclerView.
|
||||
*/
|
||||
fun attachToRecyclerView(recyclerView: RecyclerView?) {
|
||||
if (mRecyclerView === recyclerView) {
|
||||
return // nothing to do
|
||||
}
|
||||
mRecyclerView?.removeOnItemTouchListener(mOnItemTouchListener)
|
||||
mRecyclerView = recyclerView
|
||||
mRecyclerView?.let {
|
||||
it.addOnItemTouchListener(mOnItemTouchListener)
|
||||
it.addOnLayoutChangeListener(mOnLayoutChangeListener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the slide selection mode.
|
||||
*/
|
||||
fun activeSlideSelect() {
|
||||
activeSelectInternal(RecyclerView.NO_POSITION)
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the selection mode with selected item position. Normally called on long press.
|
||||
*
|
||||
* @param position Indicates the position of selected item.
|
||||
*/
|
||||
fun activeDragSelect(position: Int) {
|
||||
activeSelectInternal(position)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit the selection mode.
|
||||
*/
|
||||
fun inactiveSelect() {
|
||||
if (isActivated) {
|
||||
selectFinished(mEnd)
|
||||
} else {
|
||||
selectFinished(RecyclerView.NO_POSITION)
|
||||
}
|
||||
Logger.logSelectStateChange(mSelectState, SELECT_STATE_NORMAL)
|
||||
mSelectState = SELECT_STATE_NORMAL
|
||||
}
|
||||
|
||||
/**
|
||||
* To determine whether it is in the selection mode.
|
||||
*
|
||||
* @return true if is in the selection mode.
|
||||
*/
|
||||
val isActivated: Boolean
|
||||
get() = mSelectState != SELECT_STATE_NORMAL
|
||||
|
||||
/**
|
||||
* Sets hotspot height by ratio of RecyclerView.
|
||||
*
|
||||
* @param ratio range (0, 0.5).
|
||||
* @return The select helper, which may used to chain setter calls.
|
||||
*/
|
||||
fun setHotspotRatio(ratio: Float): DragSelectTouchHelper {
|
||||
mHotspotHeightRatio = ratio
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets hotspot height.
|
||||
*
|
||||
* @param hotspotHeight hotspot height which unit is dp.
|
||||
* @return The select helper, which may used to chain setter calls.
|
||||
*/
|
||||
fun setHotspotHeight(hotspotHeight: Int): DragSelectTouchHelper {
|
||||
mHotspotHeight = dp2px(hotspotHeight.toFloat()).toFloat()
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets hotspot offset. It don't need to be set if no special requirement.
|
||||
*
|
||||
* @param hotspotOffset hotspot offset which unit is dp.
|
||||
* @return The select helper, which may used to chain setter calls.
|
||||
*/
|
||||
fun setHotspotOffset(hotspotOffset: Int): DragSelectTouchHelper {
|
||||
mHotspotOffset = dp2px(hotspotOffset.toFloat()).toFloat()
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the activation edge type, one of:
|
||||
*
|
||||
* * [EdgeType.INSIDE] for edges that respond to touches inside
|
||||
* the bounds of the host view. If touch moves outside the bounds, scrolling
|
||||
* will stop.
|
||||
* * [EdgeType.INSIDE_EXTEND] for inside edges that continued to
|
||||
* scroll when touch moves outside the bounds of the host view.
|
||||
*
|
||||
*
|
||||
* @param type The type of edge to use.
|
||||
* @return The select helper, which may used to chain setter calls.
|
||||
*/
|
||||
fun setEdgeType(type: EdgeType?): DragSelectTouchHelper {
|
||||
when (type) {
|
||||
EdgeType.INSIDE -> {
|
||||
mScrollAboveTopRegion = false
|
||||
mScrollBelowBottomRegion = false
|
||||
}
|
||||
EdgeType.INSIDE_EXTEND -> {
|
||||
mScrollAboveTopRegion = true
|
||||
mScrollBelowBottomRegion = true
|
||||
}
|
||||
else -> {
|
||||
mScrollAboveTopRegion = true
|
||||
mScrollBelowBottomRegion = true
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets sliding area's start and end, has been considered RTL situation
|
||||
*
|
||||
* @param startDp The start of the sliding area
|
||||
* @param endDp The end of the sliding area
|
||||
* @return The select helper, which may used to chain setter calls.
|
||||
*/
|
||||
fun setSlideArea(startDp: Int, endDp: Int): DragSelectTouchHelper {
|
||||
if (!isRtl) {
|
||||
mSlideAreaLeft = dp2px(startDp.toFloat()).toFloat()
|
||||
mSlideAreaRight = dp2px(endDp.toFloat()).toFloat()
|
||||
} else {
|
||||
val displayWidth = mDisplayMetrics.widthPixels
|
||||
mSlideAreaLeft = displayWidth - dp2px(endDp.toFloat()).toFloat()
|
||||
mSlideAreaRight = displayWidth - dp2px(startDp.toFloat()).toFloat()
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum velocity for scrolling
|
||||
*
|
||||
* @param velocity maximum velocity
|
||||
* @return The select helper, which may used to chain setter calls.
|
||||
*/
|
||||
fun setMaximumVelocity(velocity: Int): DragSelectTouchHelper {
|
||||
mMaximumVelocity = (velocity * mDisplayMetrics.density + 0.5f).toInt()
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether should auto enter slide mode after drag select finished.
|
||||
* It's usefully for LinearLayout RecyclerView.
|
||||
*
|
||||
* @param autoEnterSlideState should auto enter slide mode
|
||||
* @return The select helper, which may used to chain setter calls.
|
||||
*/
|
||||
fun setAutoEnterSlideState(autoEnterSlideState: Boolean): DragSelectTouchHelper {
|
||||
mShouldAutoChangeState = autoEnterSlideState
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether can drag selection in slide select mode.
|
||||
* It's usefully for LinearLayout RecyclerView.
|
||||
*
|
||||
* @param allowDragInSlideState allow drag selection in slide select mode
|
||||
* @return The select helper, which may used to chain setter calls.
|
||||
*/
|
||||
fun setAllowDragInSlideState(allowDragInSlideState: Boolean): DragSelectTouchHelper {
|
||||
mIsAllowDragInSlideState = allowDragInSlideState
|
||||
return this
|
||||
}
|
||||
|
||||
private fun init(rvHeight: Int) {
|
||||
if (mHotspotOffset >= rvHeight * MAX_HOTSPOT_RATIO) {
|
||||
mHotspotOffset = rvHeight * MAX_HOTSPOT_RATIO
|
||||
}
|
||||
// The height of hotspot area is not set, using (RV height x ratio)
|
||||
if (mHotspotHeight <= 0) {
|
||||
if (mHotspotHeightRatio <= 0 || mHotspotHeightRatio >= MAX_HOTSPOT_RATIO) {
|
||||
mHotspotHeightRatio = DEFAULT_HOTSPOT_RATIO
|
||||
}
|
||||
mHotspotHeight = rvHeight * mHotspotHeightRatio
|
||||
} else {
|
||||
if (mHotspotHeight >= rvHeight * MAX_HOTSPOT_RATIO) {
|
||||
mHotspotHeight = rvHeight * MAX_HOTSPOT_RATIO
|
||||
}
|
||||
}
|
||||
mTopRegionFrom = mHotspotOffset
|
||||
mTopRegionTo = mTopRegionFrom + mHotspotHeight
|
||||
mBottomRegionTo = rvHeight - mHotspotOffset
|
||||
mBottomRegionFrom = mBottomRegionTo - mHotspotHeight
|
||||
if (mTopRegionTo > mBottomRegionFrom) {
|
||||
mBottomRegionFrom = (rvHeight shr 1.toFloat().toInt()).toFloat()
|
||||
mTopRegionTo = mBottomRegionFrom
|
||||
}
|
||||
Logger.d(
|
||||
"Hotspot: [" + mTopRegionFrom + ", " + mTopRegionTo + "], ["
|
||||
+ mBottomRegionFrom + ", " + mBottomRegionTo + "]"
|
||||
)
|
||||
}
|
||||
|
||||
private fun activeSelectInternal(position: Int) {
|
||||
// We should initialize the hotspot here, because its data may be delayed load
|
||||
mRecyclerView?.let {
|
||||
init(it.height)
|
||||
}
|
||||
if (position == RecyclerView.NO_POSITION) {
|
||||
Logger.logSelectStateChange(mSelectState, SELECT_STATE_SLIDE)
|
||||
mSelectState = SELECT_STATE_SLIDE
|
||||
} else {
|
||||
if (!mHaveCalledSelectStart) {
|
||||
mCallback.onSelectStart(position)
|
||||
mHaveCalledSelectStart = true
|
||||
}
|
||||
if (mSelectState == SELECT_STATE_SLIDE) {
|
||||
if (mIsAllowDragInSlideState && selectFirstItem(position)) {
|
||||
Logger.logSelectStateChange(mSelectState, SELECT_STATE_DRAG_FROM_SLIDE)
|
||||
mSelectState = SELECT_STATE_DRAG_FROM_SLIDE
|
||||
}
|
||||
} else if (mSelectState == SELECT_STATE_NORMAL) {
|
||||
if (selectFirstItem(position)) {
|
||||
Logger.logSelectStateChange(mSelectState, SELECT_STATE_DRAG_FROM_NORMAL)
|
||||
mSelectState = SELECT_STATE_DRAG_FROM_NORMAL
|
||||
}
|
||||
} else {
|
||||
Logger.e("activeSelect in unexpected state: $mSelectState")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectFirstItem(position: Int): Boolean {
|
||||
val selectFirstItemSucceed = mCallback.onSelectChange(position, true)
|
||||
// The drag select feature is only available if the first item is available for selection
|
||||
if (selectFirstItemSucceed) {
|
||||
mStart = position
|
||||
mEnd = position
|
||||
mLastRealStart = position
|
||||
mLastRealEnd = position
|
||||
}
|
||||
return selectFirstItemSucceed
|
||||
}
|
||||
|
||||
private fun updateSelectedRange(rv: RecyclerView, e: MotionEvent) {
|
||||
updateSelectedRange(rv, e.x, e.y)
|
||||
}
|
||||
|
||||
private fun updateSelectedRange(rv: RecyclerView, x: Float, y: Float) {
|
||||
val position = getItemPosition(rv, x, y)
|
||||
if (position != RecyclerView.NO_POSITION && mEnd != position) {
|
||||
mEnd = position
|
||||
notifySelectRangeChange()
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifySelectRangeChange() {
|
||||
if (mStart == RecyclerView.NO_POSITION || mEnd == RecyclerView.NO_POSITION) {
|
||||
return
|
||||
}
|
||||
val newStart: Int = min(mStart, mEnd)
|
||||
val newEnd: Int = max(mStart, mEnd)
|
||||
if (mLastRealStart == RecyclerView.NO_POSITION || mLastRealEnd == RecyclerView.NO_POSITION) {
|
||||
if (newEnd - newStart == 1) {
|
||||
notifySelectChange(newStart, newStart, true)
|
||||
} else {
|
||||
notifySelectChange(newStart, newEnd, true)
|
||||
}
|
||||
} else {
|
||||
if (newStart > mLastRealStart) {
|
||||
notifySelectChange(mLastRealStart, newStart - 1, false)
|
||||
} else if (newStart < mLastRealStart) {
|
||||
notifySelectChange(newStart, mLastRealStart - 1, true)
|
||||
}
|
||||
if (newEnd > mLastRealEnd) {
|
||||
notifySelectChange(mLastRealEnd + 1, newEnd, true)
|
||||
} else if (newEnd < mLastRealEnd) {
|
||||
notifySelectChange(newEnd + 1, mLastRealEnd, false)
|
||||
}
|
||||
}
|
||||
mLastRealStart = newStart
|
||||
mLastRealEnd = newEnd
|
||||
}
|
||||
|
||||
private fun notifySelectChange(start: Int, end: Int, newState: Boolean) {
|
||||
for (i in start..end) {
|
||||
mCallback.onSelectChange(i, newState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectFinished(lastItem: Int) {
|
||||
if (lastItem != RecyclerView.NO_POSITION) {
|
||||
mCallback.onSelectEnd(lastItem)
|
||||
}
|
||||
mStart = RecyclerView.NO_POSITION
|
||||
mEnd = RecyclerView.NO_POSITION
|
||||
mLastRealStart = RecyclerView.NO_POSITION
|
||||
mLastRealEnd = RecyclerView.NO_POSITION
|
||||
mHaveCalledSelectStart = false
|
||||
mIsInTopHotspot = false
|
||||
mIsInBottomHotspot = false
|
||||
stopAutoScroll()
|
||||
when (mSelectState) {
|
||||
SELECT_STATE_DRAG_FROM_NORMAL -> mSelectState = if (mShouldAutoChangeState) {
|
||||
Logger.logSelectStateChange(
|
||||
mSelectState,
|
||||
SELECT_STATE_SLIDE
|
||||
)
|
||||
SELECT_STATE_SLIDE
|
||||
} else {
|
||||
Logger.logSelectStateChange(
|
||||
mSelectState,
|
||||
SELECT_STATE_NORMAL
|
||||
)
|
||||
SELECT_STATE_NORMAL
|
||||
}
|
||||
SELECT_STATE_DRAG_FROM_SLIDE -> {
|
||||
Logger.logSelectStateChange(mSelectState, SELECT_STATE_SLIDE)
|
||||
mSelectState = SELECT_STATE_SLIDE
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process motion event, according to the location to determine whether to scroll
|
||||
*/
|
||||
private fun processAutoScroll(e: MotionEvent) {
|
||||
val y = e.y
|
||||
if (y in mTopRegionFrom..mTopRegionTo && y < mDownY) {
|
||||
mLastX = e.x
|
||||
mLastY = e.y
|
||||
val scrollDistanceFactor = (y - mTopRegionTo) / mHotspotHeight
|
||||
mScrollDistance = (mMaximumVelocity * scrollDistanceFactor).toInt()
|
||||
if (!mIsInTopHotspot) {
|
||||
mIsInTopHotspot = true
|
||||
startAutoScroll()
|
||||
mDownY = mTopRegionTo
|
||||
}
|
||||
} else if (mScrollAboveTopRegion && y < mTopRegionFrom && mIsInTopHotspot) {
|
||||
mLastX = e.x
|
||||
mLastY = mTopRegionFrom
|
||||
// Use the maximum speed
|
||||
mScrollDistance = mMaximumVelocity * -1
|
||||
startAutoScroll()
|
||||
} else if (y in mBottomRegionFrom..mBottomRegionTo && y > mDownY) {
|
||||
mLastX = e.x
|
||||
mLastY = e.y
|
||||
val scrollDistanceFactor = (y - mBottomRegionFrom) / mHotspotHeight
|
||||
mScrollDistance = (mMaximumVelocity * scrollDistanceFactor).toInt()
|
||||
if (!mIsInBottomHotspot) {
|
||||
mIsInBottomHotspot = true
|
||||
startAutoScroll()
|
||||
mDownY = mBottomRegionFrom
|
||||
}
|
||||
} else if (mScrollBelowBottomRegion && y > mBottomRegionTo && mIsInBottomHotspot) {
|
||||
mLastX = e.x
|
||||
mLastY = mBottomRegionTo
|
||||
// Use the maximum speed
|
||||
mScrollDistance = mMaximumVelocity
|
||||
startAutoScroll()
|
||||
} else {
|
||||
mIsInTopHotspot = false
|
||||
mIsInBottomHotspot = false
|
||||
mLastX = Float.MIN_VALUE
|
||||
mLastY = Float.MIN_VALUE
|
||||
stopAutoScroll()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAutoScroll() {
|
||||
if (!mIsScrolling) {
|
||||
mIsScrolling = true
|
||||
mRecyclerView!!.removeCallbacks(mScrollRunnable)
|
||||
ViewCompat.postOnAnimation(mRecyclerView!!, mScrollRunnable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAutoScroll() {
|
||||
if (mIsScrolling) {
|
||||
mIsScrolling = false
|
||||
mRecyclerView?.removeCallbacks(mScrollRunnable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrollBy(distance: Int) {
|
||||
val scrollDistance: Int =
|
||||
if (distance > 0) {
|
||||
min(distance, mMaximumVelocity)
|
||||
} else {
|
||||
max(distance, -mMaximumVelocity)
|
||||
}
|
||||
mRecyclerView!!.scrollBy(0, scrollDistance)
|
||||
if (mLastX != Float.MIN_VALUE && mLastY != Float.MIN_VALUE) {
|
||||
updateSelectedRange(mRecyclerView!!, mLastX, mLastY)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dp2px(dpVal: Float): Int {
|
||||
return TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
dpVal, mDisplayMetrics
|
||||
).toInt()
|
||||
}
|
||||
|
||||
private val isRtl: Boolean
|
||||
get() = (TextUtils.getLayoutDirectionFromLocale(Locale.getDefault())
|
||||
== View.LAYOUT_DIRECTION_RTL)
|
||||
|
||||
private fun isInSlideArea(e: MotionEvent): Boolean {
|
||||
val x = e.x
|
||||
return x > mSlideAreaLeft && x < mSlideAreaRight
|
||||
}
|
||||
|
||||
private fun getItemPosition(rv: RecyclerView, e: MotionEvent): Int {
|
||||
return getItemPosition(rv, e.x, e.y)
|
||||
}
|
||||
|
||||
private fun getItemPosition(rv: RecyclerView, x: Float, y: Float): Int {
|
||||
val v = rv.findChildViewUnder(x, y)
|
||||
if (v == null) {
|
||||
val layoutManager = rv.layoutManager
|
||||
if (layoutManager is GridLayoutManager) {
|
||||
val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition()
|
||||
val lastItemPosition = layoutManager.getItemCount() - 1
|
||||
if (lastItemPosition == lastVisibleItemPosition) {
|
||||
return lastItemPosition
|
||||
}
|
||||
}
|
||||
return RecyclerView.NO_POSITION
|
||||
}
|
||||
return rv.getChildAdapterPosition(v)
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge type that specifies an activation area starting at the view bounds and extending inward.
|
||||
*/
|
||||
enum class EdgeType {
|
||||
/**
|
||||
* After activation begins, moving outside the view bounds will stop scrolling.
|
||||
*/
|
||||
INSIDE,
|
||||
|
||||
/**
|
||||
* After activation begins, moving outside the view bounds will continue scrolling.
|
||||
*/
|
||||
INSIDE_EXTEND
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is the contract between DragSelectTouchHelper and your application. It lets you
|
||||
* update adapter when selection start/end and state changed.
|
||||
*/
|
||||
abstract class Callback {
|
||||
/**
|
||||
* Called when changing item state.
|
||||
*
|
||||
* @param position this item want to change the state to new state.
|
||||
* @param isSelected true if the position should be selected, false otherwise.
|
||||
* @return Whether to set the new state successfully.
|
||||
*/
|
||||
abstract fun onSelectChange(position: Int, isSelected: Boolean): Boolean
|
||||
|
||||
/**
|
||||
* Called when selection start.
|
||||
*
|
||||
* @param start the first selected item.
|
||||
*/
|
||||
open fun onSelectStart(start: Int) {}
|
||||
|
||||
/**
|
||||
* Called when selection end.
|
||||
*
|
||||
* @param end the last selected item.
|
||||
*/
|
||||
open fun onSelectEnd(end: Int) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* An advance Callback which provide 4 useful selection modes [Mode].
|
||||
*
|
||||
*
|
||||
* Note: Since the state of item may be repeatedly set, in order to improve efficiency,
|
||||
* please process it in the Adapter
|
||||
*/
|
||||
abstract class AdvanceCallback<T> : Callback {
|
||||
private var mMode: Mode? = null
|
||||
private var mOriginalSelection: MutableSet<T> = mutableSetOf()
|
||||
private var mFirstWasSelected = false
|
||||
|
||||
/**
|
||||
* Creates a SimpleCallback with default [Mode.SelectAndReverse]# mode.
|
||||
*
|
||||
* @see Mode
|
||||
*/
|
||||
constructor() {
|
||||
setMode(Mode.SelectAndReverse)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SimpleCallback with select mode.
|
||||
*
|
||||
* @param mode the initial select mode
|
||||
* @see Mode
|
||||
*/
|
||||
constructor(mode: Mode?) {
|
||||
setMode(mode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the select mode.
|
||||
*
|
||||
* @param mode The type of select mode.
|
||||
* @see Mode
|
||||
*/
|
||||
fun setMode(mode: Mode?) {
|
||||
mMode = mode
|
||||
}
|
||||
|
||||
override fun onSelectStart(start: Int) {
|
||||
mOriginalSelection.clear()
|
||||
val selected = currentSelectedId()
|
||||
if (selected != null) {
|
||||
mOriginalSelection.addAll(selected)
|
||||
}
|
||||
mFirstWasSelected = mOriginalSelection.contains(getItemId(start))
|
||||
}
|
||||
|
||||
override fun onSelectEnd(end: Int) {
|
||||
mOriginalSelection.clear()
|
||||
}
|
||||
|
||||
override fun onSelectChange(position: Int, isSelected: Boolean): Boolean {
|
||||
return when (mMode) {
|
||||
Mode.SelectAndKeep -> {
|
||||
updateSelectState(position, true)
|
||||
}
|
||||
Mode.SelectAndReverse -> {
|
||||
updateSelectState(position, isSelected)
|
||||
}
|
||||
Mode.SelectAndUndo -> {
|
||||
if (isSelected) {
|
||||
updateSelectState(position, true)
|
||||
} else {
|
||||
updateSelectState(
|
||||
position,
|
||||
mOriginalSelection.contains(getItemId(position))
|
||||
)
|
||||
}
|
||||
}
|
||||
Mode.ToggleAndKeep -> {
|
||||
updateSelectState(position, !mFirstWasSelected)
|
||||
}
|
||||
Mode.ToggleAndReverse -> {
|
||||
if (isSelected) {
|
||||
updateSelectState(position, !mFirstWasSelected)
|
||||
} else {
|
||||
updateSelectState(position, mFirstWasSelected)
|
||||
}
|
||||
}
|
||||
Mode.ToggleAndUndo -> {
|
||||
if (isSelected) {
|
||||
updateSelectState(position, !mFirstWasSelected)
|
||||
} else {
|
||||
updateSelectState(
|
||||
position,
|
||||
mOriginalSelection.contains(getItemId(position))
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> // SelectAndReverse Mode
|
||||
updateSelectState(position, isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected items when selecting first item.
|
||||
*
|
||||
* @return the currently selected item's id set.
|
||||
*/
|
||||
abstract fun currentSelectedId(): Set<T>?
|
||||
|
||||
/**
|
||||
* Get the ID of the item.
|
||||
*
|
||||
* @param position item position to be judged.
|
||||
* @return item's identity.
|
||||
*/
|
||||
abstract fun getItemId(position: Int): T
|
||||
|
||||
/**
|
||||
* Update the selection status of the position.
|
||||
*
|
||||
* @param position the position who's selection state changed.
|
||||
* @param isSelected true if the position should be selected, false otherwise.
|
||||
* @return Whether to set the state successfully.
|
||||
*/
|
||||
abstract fun updateSelectState(position: Int, isSelected: Boolean): Boolean
|
||||
|
||||
/**
|
||||
* Different existing selection modes
|
||||
*/
|
||||
enum class Mode {
|
||||
/**
|
||||
* Selects the first item and applies the same state to each item you go by
|
||||
* and keep the state on move back
|
||||
*/
|
||||
SelectAndKeep,
|
||||
|
||||
/**
|
||||
* Selects the first item and applies the same state to each item you go by
|
||||
* and applies inverted state on move back
|
||||
*/
|
||||
SelectAndReverse,
|
||||
|
||||
/**
|
||||
* Selects the first item and applies the same state to each item you go by
|
||||
* and reverts to the original state on move back
|
||||
*/
|
||||
SelectAndUndo,
|
||||
|
||||
/**
|
||||
* Toggles the first item and applies the same state to each item you go by
|
||||
* and keep the state on move back
|
||||
*/
|
||||
ToggleAndKeep,
|
||||
|
||||
/**
|
||||
* Toggles the first item and applies the same state to each item you go by
|
||||
* and applies inverted state on move back
|
||||
*/
|
||||
ToggleAndReverse,
|
||||
|
||||
/**
|
||||
* Toggles the first item and applies the same state to each item you go by
|
||||
* and reverts to the original state on move back
|
||||
*/
|
||||
ToggleAndUndo
|
||||
}
|
||||
}
|
||||
|
||||
private object Logger {
|
||||
private val DEBUG = BuildConfig.DEBUG
|
||||
fun d(msg: String) {
|
||||
DebugLog.d(javaClass.name, msg)
|
||||
}
|
||||
|
||||
fun e(msg: String) {
|
||||
DebugLog.e(javaClass.name, msg)
|
||||
}
|
||||
|
||||
fun i(msg: String) {
|
||||
DebugLog.i(javaClass.name, msg)
|
||||
}
|
||||
|
||||
fun logSelectStateChange(before: Int, after: Int) {
|
||||
i("Select state changed: " + stateName(before) + " --> " + stateName(after))
|
||||
}
|
||||
|
||||
private fun stateName(state: Int): String {
|
||||
return when (state) {
|
||||
SELECT_STATE_NORMAL -> "NormalState"
|
||||
SELECT_STATE_SLIDE -> "SlideState"
|
||||
SELECT_STATE_DRAG_FROM_NORMAL -> "DragFromNormal"
|
||||
SELECT_STATE_DRAG_FROM_SLIDE -> "DragFromSlide"
|
||||
else -> "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package io.legato.kazusa.ui.widget.recycler
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
@Suppress("unused")
|
||||
internal class HeaderAdapterDataObserver(
|
||||
private var adapterDataObserver: RecyclerView.AdapterDataObserver,
|
||||
private var headerCount: Int
|
||||
) : RecyclerView.AdapterDataObserver() {
|
||||
override fun onChanged() {
|
||||
adapterDataObserver.onChanged()
|
||||
}
|
||||
|
||||
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
|
||||
adapterDataObserver.onItemRangeChanged(positionStart + headerCount, itemCount)
|
||||
}
|
||||
|
||||
override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
|
||||
adapterDataObserver.onItemRangeChanged(positionStart + headerCount, itemCount, payload)
|
||||
}
|
||||
|
||||
// 当第n个数据被获取,更新第n+1个position
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
adapterDataObserver.onItemRangeInserted(positionStart + headerCount, itemCount)
|
||||
}
|
||||
|
||||
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
|
||||
adapterDataObserver.onItemRangeRemoved(positionStart + headerCount, itemCount)
|
||||
}
|
||||
|
||||
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
|
||||
super.onItemRangeMoved(fromPosition + headerCount, toPosition + headerCount, itemCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package io.legato.kazusa.ui.widget.recycler
|
||||
|
||||
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
|
||||
/**
|
||||
* Created by GKF on 2018/3/16.
|
||||
*/
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
class ItemTouchCallback(private val callback: Callback) : ItemTouchHelper.Callback() {
|
||||
|
||||
private var swipeRefreshLayout: SwipeRefreshLayout? = null
|
||||
|
||||
/**
|
||||
* 是否可以拖拽
|
||||
*/
|
||||
var isCanDrag = false
|
||||
|
||||
/**
|
||||
* 是否可以被滑动
|
||||
*/
|
||||
var isCanSwipe = false
|
||||
|
||||
/**
|
||||
* 当Item被长按的时候是否可以被拖拽
|
||||
*/
|
||||
override fun isLongPressDragEnabled(): Boolean {
|
||||
return isCanDrag
|
||||
}
|
||||
|
||||
/**
|
||||
* Item是否可以被滑动(H:左右滑动,V:上下滑动)
|
||||
*/
|
||||
override fun isItemViewSwipeEnabled(): Boolean {
|
||||
return isCanSwipe
|
||||
}
|
||||
|
||||
/**
|
||||
* 当用户拖拽或者滑动Item的时候需要我们告诉系统滑动或者拖拽的方向
|
||||
*/
|
||||
override fun getMovementFlags(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
): Int {
|
||||
val layoutManager = recyclerView.layoutManager
|
||||
if (layoutManager is GridLayoutManager) {// GridLayoutManager
|
||||
// flag如果值是0,相当于这个功能被关闭
|
||||
val dragFlag =
|
||||
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT or ItemTouchHelper.UP or ItemTouchHelper.DOWN
|
||||
val swipeFlag = 0
|
||||
// create make
|
||||
return makeMovementFlags(dragFlag, swipeFlag)
|
||||
} else if (layoutManager is LinearLayoutManager) {// linearLayoutManager
|
||||
val linearLayoutManager = layoutManager as LinearLayoutManager?
|
||||
val orientation = linearLayoutManager!!.orientation
|
||||
|
||||
var dragFlag = 0
|
||||
var swipeFlag = 0
|
||||
|
||||
// 为了方便理解,相当于分为横着的ListView和竖着的ListView
|
||||
if (orientation == LinearLayoutManager.HORIZONTAL) {// 如果是横向的布局
|
||||
swipeFlag = ItemTouchHelper.UP or ItemTouchHelper.DOWN
|
||||
dragFlag = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
|
||||
} else if (orientation == LinearLayoutManager.VERTICAL) {// 如果是竖向的布局,相当于ListView
|
||||
dragFlag = ItemTouchHelper.UP or ItemTouchHelper.DOWN
|
||||
swipeFlag = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
|
||||
}
|
||||
return makeMovementFlags(dragFlag, swipeFlag)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 当Item被拖拽的时候被回调
|
||||
*
|
||||
* @param recyclerView recyclerView
|
||||
* @param srcViewHolder 拖拽的ViewHolder
|
||||
* @param targetViewHolder 目的地的viewHolder
|
||||
*/
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
srcViewHolder: RecyclerView.ViewHolder,
|
||||
targetViewHolder: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
val fromPosition: Int = srcViewHolder.bindingAdapterPosition
|
||||
val toPosition: Int = targetViewHolder.bindingAdapterPosition
|
||||
if (fromPosition < toPosition) {
|
||||
for (i in fromPosition until toPosition) {
|
||||
callback.swap(i, i + 1)
|
||||
}
|
||||
} else {
|
||||
for (i in fromPosition downTo toPosition + 1) {
|
||||
callback.swap(i, i - 1)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
callback.onSwiped(viewHolder.bindingAdapterPosition)
|
||||
}
|
||||
|
||||
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
|
||||
super.onSelectedChanged(viewHolder, actionState)
|
||||
val swiping = actionState == ItemTouchHelper.ACTION_STATE_DRAG
|
||||
swipeRefreshLayout?.isEnabled = !swiping
|
||||
}
|
||||
|
||||
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||
super.clearView(recyclerView, viewHolder)
|
||||
callback.onClearView(recyclerView, viewHolder)
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
|
||||
/**
|
||||
* 当某个Item被滑动删除的时候
|
||||
*
|
||||
* @param adapterPosition item的position
|
||||
*/
|
||||
fun onSwiped(adapterPosition: Int) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 当两个Item位置互换的时候被回调
|
||||
*
|
||||
* @param srcPosition 拖拽的item的position
|
||||
* @param targetPosition 目的地的Item的position
|
||||
* @return 开发者处理了操作应该返回true,开发者没有处理就返回false
|
||||
*/
|
||||
fun swap(srcPosition: Int, targetPosition: Int): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 手指松开
|
||||
*/
|
||||
fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package io.legato.kazusa.ui.widget.recycler
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.ColorRes
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.databinding.ViewLoadMoreBinding
|
||||
import io.legato.kazusa.lib.dialogs.alert
|
||||
import io.legato.kazusa.utils.getCompatColor
|
||||
import io.legato.kazusa.utils.invisible
|
||||
import io.legato.kazusa.utils.visible
|
||||
|
||||
@Suppress("unused")
|
||||
class LoadMoreView(context: Context, attrs: AttributeSet? = null) : FrameLayout(context, attrs) {
|
||||
private val binding = ViewLoadMoreBinding.inflate(LayoutInflater.from(context), this)
|
||||
private var errorMsg = ""
|
||||
|
||||
private var onClickListener: OnClickListener? = null
|
||||
|
||||
var isLoading = false
|
||||
private set
|
||||
|
||||
var hasMore = true
|
||||
private set
|
||||
|
||||
init {
|
||||
super.setOnClickListener {
|
||||
if (!showErrorDialog()) {
|
||||
onClickListener?.onClick(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setOnClickListener(l: OnClickListener?) {
|
||||
this.onClickListener = l
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
|
||||
fun startLoad() {
|
||||
isLoading = true
|
||||
binding.tvText.invisible()
|
||||
binding.rotateLoading.visible()
|
||||
}
|
||||
|
||||
fun stopLoad() {
|
||||
isLoading = false
|
||||
binding.rotateLoading.inVisible()
|
||||
}
|
||||
|
||||
fun hasMore() {
|
||||
errorMsg = ""
|
||||
hasMore = true
|
||||
startLoad()
|
||||
}
|
||||
|
||||
fun noMore(msg: String? = null) {
|
||||
stopLoad()
|
||||
errorMsg = ""
|
||||
hasMore = false
|
||||
if (msg != null) {
|
||||
binding.tvText.text = msg
|
||||
} else {
|
||||
binding.tvText.setText(R.string.bottom_line)
|
||||
}
|
||||
binding.tvText.visible()
|
||||
}
|
||||
|
||||
fun error(msg: String?, text: String = "") {
|
||||
stopLoad()
|
||||
hasMore = false
|
||||
errorMsg = msg ?: ""
|
||||
binding.tvText.text =
|
||||
text.ifEmpty { context.getString(R.string.error_load_msg, "点击查看详情") }
|
||||
binding.tvText.visible()
|
||||
}
|
||||
|
||||
fun setLoadingColor(@ColorRes color: Int) {
|
||||
binding.rotateLoading.loadingColor = context.getCompatColor(color)
|
||||
}
|
||||
|
||||
fun setLoadingTextColor(@ColorRes color: Int) {
|
||||
binding.tvText.setTextColor(context.getCompatColor(color))
|
||||
}
|
||||
|
||||
private fun showErrorDialog(): Boolean {
|
||||
if (errorMsg.isBlank()) {
|
||||
return false
|
||||
}
|
||||
context.alert(R.string.error) {
|
||||
setMessage(errorMsg)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package io.legato.kazusa.ui.widget.recycler
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class NoChildScrollLinearLayoutManager @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
defStyleRes: Int = 0
|
||||
) : LinearLayoutManager(context, attrs, defStyleAttr, defStyleRes) {
|
||||
|
||||
override fun requestChildRectangleOnScreen(
|
||||
parent: RecyclerView,
|
||||
child: View,
|
||||
rect: Rect,
|
||||
immediate: Boolean,
|
||||
focusedChildVisible: Boolean
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun requestChildRectangleOnScreen(
|
||||
parent: RecyclerView,
|
||||
child: View,
|
||||
rect: Rect,
|
||||
immediate: Boolean
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package io.legato.kazusa.ui.widget.recycler
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlin.math.abs
|
||||
|
||||
class RecyclerViewAtPager2 : RecyclerView {
|
||||
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
|
||||
context,
|
||||
attrs,
|
||||
defStyleAttr
|
||||
)
|
||||
|
||||
private var startX = 0
|
||||
private var startY = 0
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||
when (ev.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
startX = ev.x.toInt()
|
||||
startY = ev.y.toInt()
|
||||
parent.requestDisallowInterceptTouchEvent(true)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val endX = ev.x.toInt()
|
||||
val endY = ev.y.toInt()
|
||||
val disX = abs(endX - startX)
|
||||
val disY = abs(endY - startY)
|
||||
if (disX > disY) {
|
||||
if (disX > 50) {
|
||||
parent.requestDisallowInterceptTouchEvent(false)
|
||||
}
|
||||
} else {
|
||||
parent.requestDisallowInterceptTouchEvent(true)
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP,
|
||||
MotionEvent.ACTION_CANCEL -> parent.requestDisallowInterceptTouchEvent(false)
|
||||
}
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package io.legato.kazusa.ui.widget.recycler
|
||||
|
||||
import android.content.Context
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate", "unused")
|
||||
class UpLinearLayoutManager(val context: Context) : LinearLayoutManager(context) {
|
||||
|
||||
fun smoothScrollToPosition(position: Int) {
|
||||
smoothScrollToPosition(position, 0)
|
||||
}
|
||||
|
||||
fun smoothScrollToPosition(position: Int, offset: Int) {
|
||||
val scroller = UpLinearSmoothScroller(context)
|
||||
scroller.targetPosition = position
|
||||
scroller.offset = offset
|
||||
startSmoothScroll(scroller)
|
||||
}
|
||||
|
||||
class UpLinearSmoothScroller(context: Context?) : LinearSmoothScroller(context) {
|
||||
var offset = 0
|
||||
|
||||
override fun getVerticalSnapPreference(): Int {
|
||||
return SNAP_TO_START
|
||||
}
|
||||
|
||||
override fun getHorizontalSnapPreference(): Int {
|
||||
return SNAP_TO_START
|
||||
}
|
||||
|
||||
override fun calculateDtToFit(
|
||||
viewStart: Int,
|
||||
viewEnd: Int,
|
||||
boxStart: Int,
|
||||
boxEnd: Int,
|
||||
snapPreference: Int
|
||||
): Int {
|
||||
if (snapPreference == SNAP_TO_START) {
|
||||
return boxStart - viewStart + offset
|
||||
}
|
||||
throw IllegalArgumentException("snap preference should be SNAP_TO_START")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.legato.kazusa.ui.widget.recycler
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import io.legato.kazusa.R
|
||||
|
||||
class VerticalDivider(context: Context) : DividerItemDecoration(context, VERTICAL) {
|
||||
|
||||
init {
|
||||
ContextCompat.getDrawable(context, R.drawable.ic_divider)?.let {
|
||||
this.setDrawable(it)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package io.legato.kazusa.ui.widget.recycler
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import kotlin.math.abs
|
||||
|
||||
@Suppress("unused")
|
||||
class ViewPager2Container @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : RelativeLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private var mViewPager2: ViewPager2? = null
|
||||
private var disallowParentInterceptDownEvent = true
|
||||
private var startX = 0
|
||||
private var startY = 0
|
||||
|
||||
override fun onFinishInflate() {
|
||||
super.onFinishInflate()
|
||||
for (i in 0 until childCount) {
|
||||
val childView = getChildAt(i)
|
||||
if (childView is ViewPager2) {
|
||||
mViewPager2 = childView
|
||||
break
|
||||
}
|
||||
}
|
||||
if (mViewPager2 == null) {
|
||||
throw IllegalStateException("The root child of ViewPager2Container must contains a ViewPager2")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
||||
val doNotNeedIntercept = (!mViewPager2!!.isUserInputEnabled
|
||||
|| (mViewPager2?.adapter != null
|
||||
&& mViewPager2?.adapter!!.itemCount <= 1))
|
||||
if (doNotNeedIntercept) {
|
||||
return super.onInterceptTouchEvent(ev)
|
||||
}
|
||||
when (ev.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
startX = ev.x.toInt()
|
||||
startY = ev.y.toInt()
|
||||
parent.requestDisallowInterceptTouchEvent(!disallowParentInterceptDownEvent)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val endX = ev.x.toInt()
|
||||
val endY = ev.y.toInt()
|
||||
val disX = abs(endX - startX)
|
||||
val disY = abs(endY - startY)
|
||||
if (mViewPager2!!.orientation == ViewPager2.ORIENTATION_VERTICAL) {
|
||||
onVerticalActionMove(endY, disX, disY)
|
||||
} else if (mViewPager2!!.orientation == ViewPager2.ORIENTATION_HORIZONTAL) {
|
||||
onHorizontalActionMove(endX, disX, disY)
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> parent.requestDisallowInterceptTouchEvent(
|
||||
false
|
||||
)
|
||||
}
|
||||
return super.onInterceptTouchEvent(ev)
|
||||
}
|
||||
|
||||
private fun onHorizontalActionMove(endX: Int, disX: Int, disY: Int) {
|
||||
if (mViewPager2?.adapter == null) {
|
||||
return
|
||||
}
|
||||
if (disX > disY) {
|
||||
val currentItem = mViewPager2?.currentItem
|
||||
val itemCount = mViewPager2?.adapter!!.itemCount
|
||||
if (currentItem == 0 && endX - startX > 0) {
|
||||
parent.requestDisallowInterceptTouchEvent(false)
|
||||
} else {
|
||||
parent.requestDisallowInterceptTouchEvent(
|
||||
currentItem != itemCount - 1
|
||||
|| endX - startX >= 0
|
||||
)
|
||||
}
|
||||
} else if (disY > disX) {
|
||||
parent.requestDisallowInterceptTouchEvent(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onVerticalActionMove(endY: Int, disX: Int, disY: Int) {
|
||||
if (mViewPager2?.adapter == null) {
|
||||
return
|
||||
}
|
||||
val currentItem = mViewPager2?.currentItem
|
||||
val itemCount = mViewPager2?.adapter!!.itemCount
|
||||
if (disY > disX) {
|
||||
if (currentItem == 0 && endY - startY > 0) {
|
||||
parent.requestDisallowInterceptTouchEvent(false)
|
||||
} else {
|
||||
parent.requestDisallowInterceptTouchEvent(
|
||||
currentItem != itemCount - 1
|
||||
|| endY - startY >= 0
|
||||
)
|
||||
}
|
||||
} else if (disX > disY) {
|
||||
parent.requestDisallowInterceptTouchEvent(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否允许在当前View的{@link MotionEvent#ACTION_DOWN}事件中禁止父View对事件的拦截,该方法
|
||||
* 用于解决CoordinatorLayout+CollapsingToolbarLayout在嵌套ViewPager2Container时引起的滑动冲突问题。
|
||||
*
|
||||
* 设置是否允许在ViewPager2Container的{@link MotionEvent#ACTION_DOWN}事件中禁止父View对事件的拦截,该方法
|
||||
* 用于解决CoordinatorLayout+CollapsingToolbarLayout在嵌套ViewPager2Container时引起的滑动冲突问题。
|
||||
*
|
||||
* @param disallowParentInterceptDownEvent 是否允许ViewPager2Container在{@link MotionEvent#ACTION_DOWN}事件中禁止父View拦截事件,默认值为false
|
||||
* true 不允许ViewPager2Container在{@link MotionEvent#ACTION_DOWN}时间中禁止父View的时间拦截,
|
||||
* 设置disallowIntercept为true可以解决CoordinatorLayout+CollapsingToolbarLayout的滑动冲突
|
||||
* false 允许ViewPager2Container在{@link MotionEvent#ACTION_DOWN}时间中禁止父View的时间拦截,
|
||||
*/
|
||||
fun disallowParentInterceptDownEvent(disallowParentInterceptDownEvent: Boolean) {
|
||||
this.disallowParentInterceptDownEvent = disallowParentInterceptDownEvent
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package io.legato.kazusa.ui.widget.recycler.scroller
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.legato.kazusa.R
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate", "unused")
|
||||
class FastScrollRecyclerView : RecyclerView {
|
||||
|
||||
private lateinit var mFastScroller: FastScroller
|
||||
|
||||
constructor(context: Context) : super(context) {
|
||||
layout(context, null)
|
||||
layoutParams =
|
||||
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet,
|
||||
defStyleAttr: Int = 0
|
||||
) : super(context, attrs, defStyleAttr) {
|
||||
layout(context, attrs)
|
||||
}
|
||||
|
||||
private fun layout(context: Context, attrs: AttributeSet?) {
|
||||
mFastScroller = FastScroller(context, attrs)
|
||||
mFastScroller.id = R.id.fast_scroller
|
||||
}
|
||||
|
||||
override fun setAdapter(adapter: Adapter<*>?) {
|
||||
super.setAdapter(adapter)
|
||||
if (adapter is FastScroller.SectionIndexer) {
|
||||
setSectionIndexer(adapter as FastScroller.SectionIndexer?)
|
||||
} else if (adapter == null) {
|
||||
setSectionIndexer(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun setVisibility(visibility: Int) {
|
||||
super.setVisibility(visibility)
|
||||
mFastScroller.visibility = visibility
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the [FastScroller.SectionIndexer] for the [FastScroller].
|
||||
*
|
||||
* @param sectionIndexer The SectionIndexer that provides section text for the FastScroller
|
||||
*/
|
||||
fun setSectionIndexer(sectionIndexer: FastScroller.SectionIndexer?) {
|
||||
mFastScroller.setSectionIndexer(sectionIndexer)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the enabled state of fast scrolling.
|
||||
*
|
||||
* @param enabled True to enable fast scrolling, false otherwise
|
||||
*/
|
||||
fun setFastScrollEnabled(enabled: Boolean) {
|
||||
mFastScroller.isEnabled = enabled
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Hide the scrollbar when not scrolling.
|
||||
*
|
||||
* @param hideScrollbar True to hide the scrollbar, false to show
|
||||
*/
|
||||
fun setHideScrollbar(hideScrollbar: Boolean) {
|
||||
mFastScroller.setFadeScrollbar(hideScrollbar)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a scroll track while scrolling.
|
||||
*
|
||||
* @param visible True to show scroll track, false to hide
|
||||
*/
|
||||
fun setTrackVisible(visible: Boolean) {
|
||||
mFastScroller.setTrackVisible(visible)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the color of the scroll track.
|
||||
*
|
||||
* @param color The color for the scroll track
|
||||
*/
|
||||
fun setTrackColor(@ColorInt color: Int) {
|
||||
mFastScroller.setTrackColor(color)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the color for the scroll handle.
|
||||
*
|
||||
* @param color The color for the scroll handle
|
||||
*/
|
||||
fun setHandleColor(@ColorInt color: Int) {
|
||||
mFastScroller.setHandleColor(color)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Show the section bubble while scrolling.
|
||||
*
|
||||
* @param visible True to show the bubble, false to hide
|
||||
*/
|
||||
fun setBubbleVisible(visible: Boolean) {
|
||||
mFastScroller.setBubbleVisible(visible)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the background color of the index bubble.
|
||||
*
|
||||
* @param color The background color for the index bubble
|
||||
*/
|
||||
fun setBubbleColor(@ColorInt color: Int) {
|
||||
mFastScroller.setBubbleColor(color)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the text color of the index bubble.
|
||||
*
|
||||
* @param color The text color for the index bubble
|
||||
*/
|
||||
fun setBubbleTextColor(@ColorInt color: Int) {
|
||||
mFastScroller.setBubbleTextColor(color)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the fast scroll state change listener.
|
||||
*
|
||||
* @param fastScrollStateChangeListener The interface that will listen to fastscroll state change events
|
||||
*/
|
||||
fun setFastScrollStateChangeListener(fastScrollStateChangeListener: FastScrollStateChangeListener) {
|
||||
mFastScroller.setFastScrollStateChangeListener(fastScrollStateChangeListener)
|
||||
}
|
||||
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
mFastScroller.attachRecyclerView(this)
|
||||
var parent = parent
|
||||
while (parent != null) {
|
||||
when (parent) {
|
||||
is ConstraintLayout, is CoordinatorLayout, is FrameLayout, is RelativeLayout -> break
|
||||
else -> parent = parent.parent
|
||||
}
|
||||
}
|
||||
if (parent is ViewGroup && parent.indexOfChild(mFastScroller) == -1) {
|
||||
parent.addView(mFastScroller)
|
||||
mFastScroller.setLayoutParams(parent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
mFastScroller.detachRecyclerView()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package io.legato.kazusa.ui.widget.recycler.scroller
|
||||
|
||||
interface FastScrollStateChangeListener {
|
||||
|
||||
/**
|
||||
* Called when fast scrolling begins
|
||||
*/
|
||||
fun onFastScrollStart(fastScroller: FastScroller)
|
||||
|
||||
/**
|
||||
* Called when fast scrolling ends
|
||||
*/
|
||||
fun onFastScrollStop(fastScroller: FastScroller)
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
package io.legato.kazusa.ui.widget.recycler.scroller
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewPropertyAnimator
|
||||
import android.widget.*
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.ConstraintSet
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||
import io.legato.kazusa.R
|
||||
//import io.legado.app.lib.theme.accentColor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
class FastScroller : LinearLayout {
|
||||
@ColorInt
|
||||
private var mBubbleColor: Int = 0
|
||||
|
||||
@ColorInt
|
||||
private var mHandleColor: Int = 0
|
||||
private var mBubbleHeight: Int = 0
|
||||
private var mHandleHeight: Int = 0
|
||||
private var mViewHeight: Int = 0
|
||||
private var mFadeScrollbar: Boolean = false
|
||||
private var mShowBubble: Boolean = false
|
||||
private var mSectionIndexer: SectionIndexer? = null
|
||||
private var mScrollbarAnimator: ViewPropertyAnimator? = null
|
||||
private var mBubbleAnimator: ViewPropertyAnimator? = null
|
||||
private var mRecyclerView: RecyclerView? = null
|
||||
private lateinit var mBubbleView: TextView
|
||||
private lateinit var mHandleView: ImageView
|
||||
private lateinit var mTrackView: ImageView
|
||||
private lateinit var mScrollbar: View
|
||||
private var mBubbleImage: Drawable? = null
|
||||
private var mHandleImage: Drawable? = null
|
||||
private var mTrackImage: Drawable? = null
|
||||
private var mFastScrollStateChangeListener: FastScrollStateChangeListener? = null
|
||||
private val mScrollbarHider = Runnable { this.hideScrollbar() }
|
||||
|
||||
private val mScrollListener = object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
if (!mHandleView.isSelected && isEnabled) {
|
||||
setViewPositions(getScrollProportion(recyclerView))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
||||
super.onScrollStateChanged(recyclerView, newState)
|
||||
if (isEnabled) {
|
||||
when (newState) {
|
||||
RecyclerView.SCROLL_STATE_DRAGGING -> {
|
||||
handler.removeCallbacks(mScrollbarHider)
|
||||
cancelAnimation(mScrollbarAnimator)
|
||||
if (!isViewVisible(mScrollbar)) {
|
||||
showScrollbar()
|
||||
}
|
||||
}
|
||||
RecyclerView.SCROLL_STATE_IDLE -> if (mFadeScrollbar && !mHandleView.isSelected) {
|
||||
handler.postDelayed(mScrollbarHider, sScrollbarHideDelay.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
constructor(context: Context) : super(context) {
|
||||
layout(context, null)
|
||||
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int = 0) : super(
|
||||
context,
|
||||
attrs,
|
||||
defStyleAttr
|
||||
) {
|
||||
layout(context, attrs)
|
||||
layoutParams = generateLayoutParams(attrs)
|
||||
}
|
||||
|
||||
override fun setLayoutParams(params: ViewGroup.LayoutParams) {
|
||||
params.width = LayoutParams.WRAP_CONTENT
|
||||
super.setLayoutParams(params)
|
||||
}
|
||||
|
||||
fun setLayoutParams(viewGroup: ViewGroup) {
|
||||
@IdRes val recyclerViewId = mRecyclerView?.id ?: View.NO_ID
|
||||
val marginTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top)
|
||||
val marginBottom =
|
||||
resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom)
|
||||
require(recyclerViewId != View.NO_ID) { "RecyclerView must have a view ID" }
|
||||
when (viewGroup) {
|
||||
is ConstraintLayout -> {
|
||||
val constraintSet = ConstraintSet()
|
||||
@IdRes val layoutId = id
|
||||
constraintSet.clone(viewGroup)
|
||||
constraintSet.connect(
|
||||
layoutId,
|
||||
ConstraintSet.TOP,
|
||||
recyclerViewId,
|
||||
ConstraintSet.TOP
|
||||
)
|
||||
constraintSet.connect(
|
||||
layoutId,
|
||||
ConstraintSet.BOTTOM,
|
||||
recyclerViewId,
|
||||
ConstraintSet.BOTTOM
|
||||
)
|
||||
constraintSet.connect(
|
||||
layoutId,
|
||||
ConstraintSet.END,
|
||||
recyclerViewId,
|
||||
ConstraintSet.END
|
||||
)
|
||||
constraintSet.applyTo(viewGroup)
|
||||
val layoutParams = layoutParams as ConstraintLayout.LayoutParams
|
||||
layoutParams.setMargins(0, marginTop, 0, marginBottom)
|
||||
setLayoutParams(layoutParams)
|
||||
}
|
||||
is CoordinatorLayout -> {
|
||||
val layoutParams = layoutParams as CoordinatorLayout.LayoutParams
|
||||
layoutParams.anchorId = recyclerViewId
|
||||
layoutParams.anchorGravity = GravityCompat.END
|
||||
layoutParams.setMargins(0, marginTop, 0, marginBottom)
|
||||
setLayoutParams(layoutParams)
|
||||
}
|
||||
is FrameLayout -> {
|
||||
val layoutParams = layoutParams as FrameLayout.LayoutParams
|
||||
layoutParams.gravity = GravityCompat.END
|
||||
layoutParams.setMargins(0, marginTop, 0, marginBottom)
|
||||
setLayoutParams(layoutParams)
|
||||
}
|
||||
is RelativeLayout -> {
|
||||
val layoutParams = layoutParams as RelativeLayout.LayoutParams
|
||||
val endRule = RelativeLayout.ALIGN_END
|
||||
layoutParams.addRule(RelativeLayout.ALIGN_TOP, recyclerViewId)
|
||||
layoutParams.addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId)
|
||||
layoutParams.addRule(endRule, recyclerViewId)
|
||||
layoutParams.setMargins(0, marginTop, 0, marginBottom)
|
||||
setLayoutParams(layoutParams)
|
||||
}
|
||||
else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout")
|
||||
}
|
||||
updateViewHeights()
|
||||
}
|
||||
|
||||
fun setSectionIndexer(sectionIndexer: SectionIndexer?) {
|
||||
mSectionIndexer = sectionIndexer
|
||||
}
|
||||
|
||||
fun attachRecyclerView(recyclerView: RecyclerView) {
|
||||
mRecyclerView = recyclerView
|
||||
mRecyclerView!!.addOnScrollListener(mScrollListener)
|
||||
post {
|
||||
// set initial positions for bubble and handle
|
||||
setViewPositions(getScrollProportion(mRecyclerView))
|
||||
}
|
||||
}
|
||||
|
||||
fun detachRecyclerView() {
|
||||
if (mRecyclerView != null) {
|
||||
mRecyclerView!!.removeOnScrollListener(mScrollListener)
|
||||
mRecyclerView = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the scrollbar when not scrolling.
|
||||
*
|
||||
* @param fadeScrollbar True to hide the scrollbar, false to show
|
||||
*/
|
||||
fun setFadeScrollbar(fadeScrollbar: Boolean) {
|
||||
mFadeScrollbar = fadeScrollbar
|
||||
mScrollbar.visibility = if (fadeScrollbar) View.INVISIBLE else View.VISIBLE
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the section bubble while scrolling.
|
||||
*
|
||||
* @param visible True to show the bubble, false to hide
|
||||
*/
|
||||
fun setBubbleVisible(visible: Boolean) {
|
||||
mShowBubble = visible
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a scroll track while scrolling.
|
||||
*
|
||||
* @param visible True to show scroll track, false to hide
|
||||
*/
|
||||
fun setTrackVisible(visible: Boolean) {
|
||||
mTrackView.visibility = if (visible) View.VISIBLE else View.INVISIBLE
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the color of the scroll track.
|
||||
*
|
||||
* @param color The color for the scroll track
|
||||
*/
|
||||
fun setTrackColor(@ColorInt color: Int) {
|
||||
if (mTrackImage == null) {
|
||||
val drawable = ContextCompat.getDrawable(context, R.drawable.fastscroll_track)
|
||||
if (drawable != null) {
|
||||
mTrackImage = DrawableCompat.wrap(drawable)
|
||||
}
|
||||
}
|
||||
DrawableCompat.setTint(mTrackImage!!, color)
|
||||
mTrackView.setImageDrawable(mTrackImage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the color for the scroll handle.
|
||||
*
|
||||
* @param color The color for the scroll handle
|
||||
*/
|
||||
fun setHandleColor(@ColorInt color: Int) {
|
||||
mHandleColor = color
|
||||
if (mHandleImage == null) {
|
||||
val drawable = ContextCompat.getDrawable(context, R.drawable.fastscroll_handle)
|
||||
if (drawable != null) {
|
||||
mHandleImage = DrawableCompat.wrap(drawable)
|
||||
}
|
||||
}
|
||||
DrawableCompat.setTint(mHandleImage!!, mHandleColor)
|
||||
mHandleView.setImageDrawable(mHandleImage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the background color of the index bubble.
|
||||
*
|
||||
* @param color The background color for the index bubble
|
||||
*/
|
||||
fun setBubbleColor(@ColorInt color: Int) {
|
||||
mBubbleColor = color
|
||||
if (mBubbleImage == null) {
|
||||
val drawable = ContextCompat.getDrawable(context, R.drawable.fastscroll_bubble)
|
||||
if (drawable != null) {
|
||||
mBubbleImage = DrawableCompat.wrap(drawable)
|
||||
}
|
||||
}
|
||||
DrawableCompat.setTint(mBubbleImage!!, mBubbleColor)
|
||||
mBubbleView.background = mBubbleImage
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the text color of the index bubble.
|
||||
*
|
||||
* @param color The text color for the index bubble
|
||||
*/
|
||||
fun setBubbleTextColor(@ColorInt color: Int) {
|
||||
mBubbleView.setTextColor(color)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the fast scroll state change listener.
|
||||
*
|
||||
* @param fastScrollStateChangeListener The interface that will listen to fastscroll state change events
|
||||
*/
|
||||
fun setFastScrollStateChangeListener(fastScrollStateChangeListener: FastScrollStateChangeListener) {
|
||||
mFastScrollStateChangeListener = fastScrollStateChangeListener
|
||||
}
|
||||
|
||||
override fun setEnabled(enabled: Boolean) {
|
||||
super.setEnabled(enabled)
|
||||
visibility = if (enabled) View.VISIBLE else View.INVISIBLE
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
if (event.x < mHandleView.x - ViewCompat.getPaddingStart(mHandleView)) {
|
||||
return false
|
||||
}
|
||||
if (!mScrollbar.isVisible) {
|
||||
return false
|
||||
}
|
||||
requestDisallowInterceptTouchEvent(true)
|
||||
setHandleSelected(true)
|
||||
handler.removeCallbacks(mScrollbarHider)
|
||||
cancelAnimation(mScrollbarAnimator)
|
||||
cancelAnimation(mBubbleAnimator)
|
||||
if (mShowBubble && mSectionIndexer != null) {
|
||||
showBubble()
|
||||
}
|
||||
if (mFastScrollStateChangeListener != null) {
|
||||
mFastScrollStateChangeListener!!.onFastScrollStart(this)
|
||||
}
|
||||
val y = event.y
|
||||
setViewPositions(y)
|
||||
setRecyclerViewPosition(y)
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val y = event.y
|
||||
setViewPositions(y)
|
||||
setRecyclerViewPosition(y)
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
requestDisallowInterceptTouchEvent(false)
|
||||
setHandleSelected(false)
|
||||
if (mFadeScrollbar) {
|
||||
handler.postDelayed(mScrollbarHider, sScrollbarHideDelay.toLong())
|
||||
}
|
||||
hideBubble()
|
||||
if (mFastScrollStateChangeListener != null) {
|
||||
mFastScrollStateChangeListener!!.onFastScrollStop(this)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
mViewHeight = h
|
||||
}
|
||||
|
||||
private fun setRecyclerViewPosition(y: Float) {
|
||||
mRecyclerView?.adapter?.let { adapter ->
|
||||
val itemCount = adapter.itemCount
|
||||
val proportion: Float = when {
|
||||
mHandleView.y == 0f -> 0f
|
||||
mHandleView.y + mHandleHeight >= mViewHeight - sTrackSnapRange -> 1f
|
||||
else -> y / mViewHeight.toFloat()
|
||||
}
|
||||
var scrolledItemCount = (proportion * itemCount).roundToInt()
|
||||
if (isLayoutReversed(mRecyclerView?.layoutManager)) {
|
||||
scrolledItemCount = itemCount - scrolledItemCount
|
||||
}
|
||||
val targetPos = getValueInRange(0, itemCount - 1, scrolledItemCount)
|
||||
mRecyclerView?.layoutManager?.scrollToPosition(targetPos)
|
||||
mSectionIndexer?.let { sectionIndexer ->
|
||||
if (mShowBubble) {
|
||||
mBubbleView.text = sectionIndexer.getSectionText(targetPos)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getScrollProportion(recyclerView: RecyclerView?): Float {
|
||||
recyclerView ?: return 0f
|
||||
val verticalScrollOffset = recyclerView.computeVerticalScrollOffset()
|
||||
val verticalScrollRange = recyclerView.computeVerticalScrollRange()
|
||||
val rangeDiff = (verticalScrollRange - mViewHeight).toFloat()
|
||||
val proportion = verticalScrollOffset.toFloat() / if (rangeDiff > 0) rangeDiff else 1f
|
||||
return mViewHeight * proportion
|
||||
}
|
||||
|
||||
private fun getValueInRange(min: Int, max: Int, value: Int): Int {
|
||||
val minimum = max(min, value)
|
||||
return min(minimum, max)
|
||||
}
|
||||
|
||||
private fun setViewPositions(y: Float) {
|
||||
mBubbleHeight = mBubbleView.height
|
||||
mHandleHeight = mHandleView.height
|
||||
val bubbleY = getValueInRange(
|
||||
0,
|
||||
mViewHeight - mBubbleHeight - mHandleHeight / 2,
|
||||
(y - mBubbleHeight).toInt()
|
||||
)
|
||||
val handleY =
|
||||
getValueInRange(0, mViewHeight - mHandleHeight, (y - mHandleHeight / 2).toInt())
|
||||
if (mShowBubble) {
|
||||
mBubbleView.y = bubbleY.toFloat()
|
||||
}
|
||||
mHandleView.y = handleY.toFloat()
|
||||
}
|
||||
|
||||
private fun updateViewHeights() {
|
||||
val measureSpec =
|
||||
MeasureSpec.makeMeasureSpec(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
|
||||
mBubbleView.measure(measureSpec, measureSpec)
|
||||
mBubbleHeight = mBubbleView.measuredHeight
|
||||
mHandleView.measure(measureSpec, measureSpec)
|
||||
mHandleHeight = mHandleView.measuredHeight
|
||||
}
|
||||
|
||||
private fun isLayoutReversed(layoutManager: RecyclerView.LayoutManager?): Boolean {
|
||||
if (layoutManager is LinearLayoutManager) {
|
||||
return layoutManager.reverseLayout
|
||||
} else if (layoutManager is StaggeredGridLayoutManager) {
|
||||
return layoutManager.reverseLayout
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun isViewVisible(view: View?): Boolean {
|
||||
return view != null && view.isVisible
|
||||
}
|
||||
|
||||
private fun cancelAnimation(animator: ViewPropertyAnimator?) {
|
||||
animator?.cancel()
|
||||
}
|
||||
|
||||
private fun showBubble() {
|
||||
if (!isViewVisible(mBubbleView)) {
|
||||
mBubbleView.visibility = View.VISIBLE
|
||||
mBubbleAnimator = mBubbleView.animate().alpha(1f)
|
||||
.setDuration(sBubbleAnimDuration.toLong())
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
|
||||
// adapter required for new alpha value to stick
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideBubble() {
|
||||
if (isViewVisible(mBubbleView)) {
|
||||
mBubbleAnimator = mBubbleView.animate().alpha(0f)
|
||||
.setDuration(sBubbleAnimDuration.toLong())
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
super.onAnimationEnd(animation)
|
||||
mBubbleView.visibility = View.INVISIBLE
|
||||
mBubbleAnimator = null
|
||||
}
|
||||
|
||||
override fun onAnimationCancel(animation: Animator) {
|
||||
super.onAnimationCancel(animation)
|
||||
mBubbleView.visibility = View.INVISIBLE
|
||||
mBubbleAnimator = null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun showScrollbar() {
|
||||
mRecyclerView?.let { mRecyclerView ->
|
||||
if (mRecyclerView.computeVerticalScrollRange() - mViewHeight > 0) {
|
||||
val transX =
|
||||
resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_padding_end)
|
||||
.toFloat()
|
||||
mScrollbar.translationX = transX
|
||||
mScrollbar.visibility = View.VISIBLE
|
||||
mScrollbarAnimator = mScrollbar.animate().translationX(0f).alpha(1f)
|
||||
.setDuration(sScrollbarAnimDuration.toLong())
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
|
||||
// adapter required for new alpha value to stick
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideScrollbar() {
|
||||
val transX =
|
||||
resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_padding_end).toFloat()
|
||||
mScrollbarAnimator = mScrollbar.animate().translationX(transX).alpha(0f)
|
||||
.setDuration(sScrollbarAnimDuration.toLong())
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
super.onAnimationEnd(animation)
|
||||
mScrollbar.visibility = View.INVISIBLE
|
||||
mScrollbarAnimator = null
|
||||
}
|
||||
|
||||
override fun onAnimationCancel(animation: Animator) {
|
||||
super.onAnimationCancel(animation)
|
||||
mScrollbar.visibility = View.INVISIBLE
|
||||
mScrollbarAnimator = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun setHandleSelected(selected: Boolean) {
|
||||
mHandleView.isSelected = selected
|
||||
DrawableCompat.setTint(mHandleImage!!, if (selected) mHandleColor else mHandleColor)
|
||||
}
|
||||
|
||||
private fun layout(context: Context, attrs: AttributeSet?) {
|
||||
View.inflate(context, R.layout.view_fastscroller, this)
|
||||
clipChildren = false
|
||||
orientation = HORIZONTAL
|
||||
mBubbleView = findViewById(R.id.fastscroll_bubble)
|
||||
mHandleView = findViewById(R.id.fastscroll_handle)
|
||||
mTrackView = findViewById(R.id.fastscroll_track)
|
||||
mScrollbar = findViewById(R.id.fastscroll_scrollbar)
|
||||
mHandleImage = mHandleView.drawable?.mutate()
|
||||
val typedValue = TypedValue()
|
||||
context.theme.resolveAttribute(com.google.android.material.R.attr.colorPrimary, typedValue, true)
|
||||
val defaultColor = typedValue.data
|
||||
var bubbleColor = defaultColor
|
||||
var handleColor = defaultColor
|
||||
var trackColor = defaultColor
|
||||
var textColor = defaultColor
|
||||
var fadeScrollbar = true
|
||||
var showBubble = false
|
||||
var showTrack = true
|
||||
if (attrs != null) {
|
||||
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.FastScroller, 0, 0)
|
||||
try {
|
||||
bubbleColor = typedArray.getColor(R.styleable.FastScroller_bubbleColor, bubbleColor)
|
||||
handleColor = typedArray.getColor(R.styleable.FastScroller_handleColor, handleColor)
|
||||
trackColor = typedArray.getColor(R.styleable.FastScroller_trackColor, trackColor)
|
||||
textColor = typedArray.getColor(R.styleable.FastScroller_bubbleTextColor, textColor)
|
||||
fadeScrollbar =
|
||||
typedArray.getBoolean(R.styleable.FastScroller_fadeScrollbar, fadeScrollbar)
|
||||
showBubble = typedArray.getBoolean(R.styleable.FastScroller_showBubble, showBubble)
|
||||
showTrack = typedArray.getBoolean(R.styleable.FastScroller_showTrack, showTrack)
|
||||
} finally {
|
||||
typedArray.recycle()
|
||||
}
|
||||
}
|
||||
setTrackColor(trackColor)
|
||||
setHandleColor(handleColor)
|
||||
setBubbleColor(bubbleColor)
|
||||
setBubbleTextColor(textColor)
|
||||
setFadeScrollbar(fadeScrollbar)
|
||||
setBubbleVisible(showBubble)
|
||||
setTrackVisible(showTrack)
|
||||
}
|
||||
|
||||
interface SectionIndexer {
|
||||
fun getSectionText(position: Int): String
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val sBubbleAnimDuration = 100
|
||||
private const val sScrollbarAnimDuration = 300
|
||||
private const val sScrollbarHideDelay = 1000
|
||||
private const val sTrackSnapRange = 5
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package io.legato.kazusa.ui.widget.seekbar
|
||||
|
||||
import android.widget.SeekBar
|
||||
|
||||
interface SeekBarChangeListener : SeekBar.OnSeekBarChangeListener {
|
||||
|
||||
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
|
||||
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar) {
|
||||
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package io.legato.kazusa.ui.widget.seekbar
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.widget.ProgressBar
|
||||
import androidx.appcompat.widget.AppCompatSeekBar
|
||||
import androidx.core.view.ViewCompat
|
||||
import io.legato.kazusa.R
|
||||
//import io.legado.app.lib.theme.accentColor
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.lang.reflect.Method
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
class VerticalSeekBar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
|
||||
AppCompatSeekBar(context, attrs) {
|
||||
|
||||
private var mIsDragging: Boolean = false
|
||||
private var mThumb: Drawable? = null
|
||||
private var mMethodSetProgressFromUser: Method? = null
|
||||
private var mRotationAngle = ROTATION_ANGLE_CW_90
|
||||
|
||||
var rotationAngle: Int
|
||||
get() = mRotationAngle
|
||||
set(angle) {
|
||||
require(isValidRotationAngle(angle)) { "Invalid angle specified :$angle" }
|
||||
|
||||
if (mRotationAngle == angle) {
|
||||
return
|
||||
}
|
||||
|
||||
mRotationAngle = angle
|
||||
|
||||
if (useViewRotation()) {
|
||||
val wrapper = wrapper
|
||||
wrapper?.applyViewRotation()
|
||||
} else {
|
||||
requestLayout()
|
||||
}
|
||||
}
|
||||
|
||||
private val wrapper: VerticalSeekBarWrapper?
|
||||
get() {
|
||||
val parent = parent
|
||||
|
||||
return if (parent is VerticalSeekBarWrapper) {
|
||||
parent
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
if (!isInEditMode) {
|
||||
//applyTint(context.accentColor)
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
ViewCompat.setLayoutDirection(this, ViewCompat.LAYOUT_DIRECTION_LTR)
|
||||
|
||||
if (attrs != null) {
|
||||
val a = context.obtainStyledAttributes(attrs, R.styleable.VerticalSeekBar)
|
||||
val rotationAngle = a.getInteger(R.styleable.VerticalSeekBar_seekBarRotation, 0)
|
||||
if (isValidRotationAngle(rotationAngle)) {
|
||||
mRotationAngle = rotationAngle
|
||||
}
|
||||
a.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setThumb(thumb: Drawable?) {
|
||||
mThumb = thumb
|
||||
super.setThumb(thumb)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
return if (useViewRotation()) {
|
||||
onTouchEventUseViewRotation(event)
|
||||
} else {
|
||||
onTouchEventTraditionalRotation(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onTouchEventTraditionalRotation(event: MotionEvent): Boolean {
|
||||
if (!isEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
isPressed = true
|
||||
onStartTrackingTouch()
|
||||
trackTouchEvent(event)
|
||||
attemptClaimDrag(true)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> if (mIsDragging) {
|
||||
trackTouchEvent(event)
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (mIsDragging) {
|
||||
trackTouchEvent(event)
|
||||
onStopTrackingTouch()
|
||||
isPressed = false
|
||||
} else {
|
||||
// Touch up when we never crossed the touch slop threshold
|
||||
// should
|
||||
// be interpreted as a tap-seek to that location.
|
||||
onStartTrackingTouch()
|
||||
trackTouchEvent(event)
|
||||
onStopTrackingTouch()
|
||||
attemptClaimDrag(false)
|
||||
}
|
||||
// ProgressBar doesn't know to repaint the thumb drawable
|
||||
// in its inactive state when the touch stops (because the
|
||||
// value has not apparently changed)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
if (mIsDragging) {
|
||||
onStopTrackingTouch()
|
||||
isPressed = false
|
||||
}
|
||||
invalidate() // see above explanation
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onTouchEventUseViewRotation(event: MotionEvent): Boolean {
|
||||
val handled = super.onTouchEvent(event)
|
||||
|
||||
if (handled) {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> attemptClaimDrag(true)
|
||||
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> attemptClaimDrag(false)
|
||||
}
|
||||
}
|
||||
|
||||
return handled
|
||||
}
|
||||
|
||||
private fun trackTouchEvent(event: MotionEvent) {
|
||||
val paddingLeft = super.getPaddingLeft()
|
||||
val paddingRight = super.getPaddingRight()
|
||||
val height = height
|
||||
|
||||
val available = height - paddingLeft - paddingRight
|
||||
val y = event.y.toInt()
|
||||
|
||||
val scale: Float
|
||||
var value = 0f
|
||||
|
||||
when (mRotationAngle) {
|
||||
ROTATION_ANGLE_CW_90 -> value = (y - paddingLeft).toFloat()
|
||||
ROTATION_ANGLE_CW_270 -> value = (height - paddingLeft - y).toFloat()
|
||||
}
|
||||
|
||||
scale = if (value < 0 || available == 0) {
|
||||
0.0f
|
||||
} else if (value > available) {
|
||||
1.0f
|
||||
} else {
|
||||
value / available.toFloat()
|
||||
}
|
||||
|
||||
val max = max
|
||||
val progress = scale * max
|
||||
|
||||
setProgressFromUser(progress.toInt(), true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to claim the user's drag motion, and requests disallowing any
|
||||
* ancestors from stealing events in the drag.
|
||||
*/
|
||||
private fun attemptClaimDrag(active: Boolean) {
|
||||
val parent = parent
|
||||
parent?.requestDisallowInterceptTouchEvent(active)
|
||||
}
|
||||
|
||||
/**
|
||||
* This is called when the user has started touching this widget.
|
||||
*/
|
||||
private fun onStartTrackingTouch() {
|
||||
mIsDragging = true
|
||||
}
|
||||
|
||||
/**
|
||||
* This is called when the user either releases his touch or the touch is
|
||||
* canceled.
|
||||
*/
|
||||
private fun onStopTrackingTouch() {
|
||||
mIsDragging = false
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (isEnabled) {
|
||||
val handled: Boolean
|
||||
var direction = 0
|
||||
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||
direction = if (mRotationAngle == ROTATION_ANGLE_CW_90) 1 else -1
|
||||
handled = true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_DPAD_UP -> {
|
||||
direction = if (mRotationAngle == ROTATION_ANGLE_CW_270) 1 else -1
|
||||
handled = true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT ->
|
||||
// move view focus to previous/next view
|
||||
return false
|
||||
|
||||
else -> handled = false
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
val keyProgressIncrement = keyProgressIncrement
|
||||
var progress = progress
|
||||
|
||||
progress += direction * keyProgressIncrement
|
||||
|
||||
if (progress in 0..max) {
|
||||
setProgressFromUser(progress, true)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun setProgress(progress: Int) {
|
||||
super.setProgress(progress)
|
||||
if (!useViewRotation()) {
|
||||
refreshThumb()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun setProgressFromUser(progress: Int, fromUser: Boolean) {
|
||||
if (mMethodSetProgressFromUser == null) {
|
||||
try {
|
||||
val m: Method = ProgressBar::class.java.getDeclaredMethod(
|
||||
"setProgress",
|
||||
Int::class.javaPrimitiveType,
|
||||
Boolean::class.javaPrimitiveType
|
||||
)
|
||||
m.isAccessible = true
|
||||
mMethodSetProgressFromUser = m
|
||||
} catch (_: NoSuchMethodException) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (mMethodSetProgressFromUser != null) {
|
||||
try {
|
||||
mMethodSetProgressFromUser!!.invoke(this, progress, fromUser)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
} catch (_: IllegalAccessException) {
|
||||
} catch (_: InvocationTargetException) {
|
||||
}
|
||||
|
||||
} else {
|
||||
super.setProgress(progress)
|
||||
}
|
||||
refreshThumb()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
if (useViewRotation()) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
} else {
|
||||
super.onMeasure(heightMeasureSpec, widthMeasureSpec)
|
||||
|
||||
val lp = layoutParams
|
||||
|
||||
if (isInEditMode && lp != null && lp.height >= 0) {
|
||||
setMeasuredDimension(super.getMeasuredHeight(), lp.height)
|
||||
} else {
|
||||
setMeasuredDimension(super.getMeasuredHeight(), super.getMeasuredWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
if (useViewRotation()) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
} else {
|
||||
super.onSizeChanged(h, w, oldh, oldw)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (!useViewRotation()) {
|
||||
when (mRotationAngle) {
|
||||
ROTATION_ANGLE_CW_90 -> {
|
||||
canvas.rotate(90f)
|
||||
canvas.translate(0f, (-super.getWidth()).toFloat())
|
||||
}
|
||||
|
||||
ROTATION_ANGLE_CW_270 -> {
|
||||
canvas.rotate(-90f)
|
||||
canvas.translate((-super.getHeight()).toFloat(), 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super.onDraw(canvas)
|
||||
}
|
||||
|
||||
// refresh thumb position
|
||||
private fun refreshThumb() {
|
||||
onSizeChanged(super.getWidth(), super.getHeight(), 0, 0)
|
||||
}
|
||||
|
||||
/*package*/
|
||||
internal fun useViewRotation(): Boolean {
|
||||
return !isInEditMode
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ROTATION_ANGLE_CW_90 = 90
|
||||
const val ROTATION_ANGLE_CW_270 = 270
|
||||
|
||||
private fun isValidRotationAngle(angle: Int): Boolean {
|
||||
return angle == ROTATION_ANGLE_CW_90 || angle == ROTATION_ANGLE_CW_270
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package io.legato.kazusa.ui.widget.seekbar
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
|
||||
import androidx.core.view.ViewCompat
|
||||
import kotlin.math.max
|
||||
|
||||
class VerticalSeekBarWrapper @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
|
||||
private val childSeekBar: VerticalSeekBar?
|
||||
get() {
|
||||
val child = if (childCount > 0) getChildAt(0) else null
|
||||
return if (child is VerticalSeekBar) child else null
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
if (useViewRotation()) {
|
||||
onSizeChangedUseViewRotation(w, h, oldw, oldh)
|
||||
} else {
|
||||
onSizeChangedTraditionalRotation(w, h, oldw, oldh)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RtlHardcoded")
|
||||
private fun onSizeChangedTraditionalRotation(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
val seekBar = childSeekBar
|
||||
|
||||
if (seekBar != null) {
|
||||
val hPadding = paddingLeft + paddingRight
|
||||
val vPadding = paddingTop + paddingBottom
|
||||
val lp = seekBar.layoutParams as LayoutParams
|
||||
|
||||
lp.width = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
lp.height = max(0, h - vPadding)
|
||||
seekBar.layoutParams = lp
|
||||
|
||||
seekBar.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
|
||||
|
||||
val seekBarMeasuredWidth = seekBar.measuredWidth
|
||||
seekBar.measure(
|
||||
MeasureSpec.makeMeasureSpec(
|
||||
max(0, w - hPadding),
|
||||
MeasureSpec.AT_MOST
|
||||
),
|
||||
MeasureSpec.makeMeasureSpec(
|
||||
max(0, h - vPadding),
|
||||
MeasureSpec.EXACTLY
|
||||
)
|
||||
)
|
||||
|
||||
lp.gravity = Gravity.TOP or Gravity.LEFT
|
||||
lp.leftMargin = (max(0, w - hPadding) - seekBarMeasuredWidth) / 2
|
||||
seekBar.layoutParams = lp
|
||||
}
|
||||
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
}
|
||||
|
||||
private fun onSizeChangedUseViewRotation(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
val seekBar = childSeekBar
|
||||
|
||||
if (seekBar != null) {
|
||||
val hPadding = paddingLeft + paddingRight
|
||||
val vPadding = paddingTop + paddingBottom
|
||||
seekBar.measure(
|
||||
MeasureSpec.makeMeasureSpec(
|
||||
max(0, h - vPadding),
|
||||
MeasureSpec.EXACTLY
|
||||
),
|
||||
MeasureSpec.makeMeasureSpec(
|
||||
max(0, w - hPadding),
|
||||
MeasureSpec.AT_MOST
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
applyViewRotation(w, h)
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val seekBar = childSeekBar
|
||||
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
|
||||
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
|
||||
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
|
||||
|
||||
if (seekBar != null && widthMode != MeasureSpec.EXACTLY) {
|
||||
val seekBarWidth: Int
|
||||
val seekBarHeight: Int
|
||||
val hPadding = paddingLeft + paddingRight
|
||||
val vPadding = paddingTop + paddingBottom
|
||||
val innerContentWidthMeasureSpec =
|
||||
MeasureSpec.makeMeasureSpec(max(0, widthSize - hPadding), widthMode)
|
||||
val innerContentHeightMeasureSpec =
|
||||
MeasureSpec.makeMeasureSpec(max(0, heightSize - vPadding), heightMode)
|
||||
|
||||
if (useViewRotation()) {
|
||||
seekBar.measure(innerContentHeightMeasureSpec, innerContentWidthMeasureSpec)
|
||||
seekBarWidth = seekBar.measuredHeight
|
||||
seekBarHeight = seekBar.measuredWidth
|
||||
} else {
|
||||
seekBar.measure(innerContentWidthMeasureSpec, innerContentHeightMeasureSpec)
|
||||
seekBarWidth = seekBar.measuredWidth
|
||||
seekBarHeight = seekBar.measuredHeight
|
||||
}
|
||||
|
||||
val measuredWidth =
|
||||
View.resolveSizeAndState(seekBarWidth + hPadding, widthMeasureSpec, 0)
|
||||
val measuredHeight =
|
||||
View.resolveSizeAndState(seekBarHeight + vPadding, heightMeasureSpec, 0)
|
||||
|
||||
setMeasuredDimension(measuredWidth, measuredHeight)
|
||||
} else {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
}
|
||||
}
|
||||
|
||||
/*package*/
|
||||
internal fun applyViewRotation() {
|
||||
applyViewRotation(width, height)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun applyViewRotation(w: Int, h: Int) {
|
||||
val seekBar = childSeekBar
|
||||
|
||||
if (seekBar != null) {
|
||||
val isLTR = ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_LTR
|
||||
val rotationAngle = seekBar.rotationAngle
|
||||
val seekBarMeasuredWidth = seekBar.measuredWidth
|
||||
val seekBarMeasuredHeight = seekBar.measuredHeight
|
||||
val hPadding = paddingLeft + paddingRight
|
||||
val vPadding = paddingTop + paddingBottom
|
||||
val hOffset = (max(0, w - hPadding) - seekBarMeasuredHeight) * 0.5f
|
||||
val lp = seekBar.layoutParams
|
||||
|
||||
lp.width = max(0, h - vPadding)
|
||||
lp.height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
|
||||
seekBar.layoutParams = lp
|
||||
|
||||
seekBar.pivotX = (if (isLTR) 0 else max(0, h - vPadding)).toFloat()
|
||||
seekBar.pivotY = 0f
|
||||
|
||||
when (rotationAngle) {
|
||||
VerticalSeekBar.ROTATION_ANGLE_CW_90 -> {
|
||||
seekBar.rotation = 90f
|
||||
if (isLTR) {
|
||||
seekBar.translationX = seekBarMeasuredHeight + hOffset
|
||||
seekBar.translationY = 0f
|
||||
} else {
|
||||
seekBar.translationX = -hOffset
|
||||
seekBar.translationY = seekBarMeasuredWidth.toFloat()
|
||||
}
|
||||
}
|
||||
VerticalSeekBar.ROTATION_ANGLE_CW_270 -> {
|
||||
seekBar.rotation = 270f
|
||||
if (isLTR) {
|
||||
seekBar.translationX = hOffset
|
||||
seekBar.translationY = seekBarMeasuredWidth.toFloat()
|
||||
} else {
|
||||
seekBar.translationX = -(seekBarMeasuredHeight + hOffset)
|
||||
seekBar.translationY = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun useViewRotation(): Boolean {
|
||||
val seekBar = childSeekBar
|
||||
return seekBar?.useViewRotation() ?: false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package io.legato.kazusa.ui.widget.text
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.textview.MaterialTextView
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.lib.theme.Selector
|
||||
import io.legato.kazusa.utils.ColorUtils
|
||||
import io.legato.kazusa.utils.dpToPx
|
||||
import androidx.core.content.withStyledAttributes
|
||||
|
||||
class AccentBgTextView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : MaterialTextView(context, attrs) {
|
||||
|
||||
private var radiusPx = 0
|
||||
|
||||
init {
|
||||
context.withStyledAttributes(attrs, R.styleable.AccentBgTextView) {
|
||||
val radiusDp = getDimensionPixelOffset(R.styleable.AccentBgTextView_radius, 0)
|
||||
radiusPx = radiusDp
|
||||
}
|
||||
updateBackground()
|
||||
}
|
||||
|
||||
fun setRadius(dp: Int) {
|
||||
radiusPx = dp.dpToPx()
|
||||
updateBackground()
|
||||
}
|
||||
|
||||
private fun updateBackground() {
|
||||
val backgroundColor = MaterialColors.getColor(
|
||||
this,
|
||||
com.google.android.material.R.attr.colorPrimaryContainer,
|
||||
//context.getColorCompat(R.color.fallback_bg)
|
||||
)
|
||||
val textColor = MaterialColors.getColor(
|
||||
this,
|
||||
com.google.android.material.R.attr.colorOnPrimaryContainer,
|
||||
//context.getColorCompat(R.color.fallback_text)
|
||||
)
|
||||
|
||||
background = Selector.shapeBuild()
|
||||
.setCornerRadius(radiusPx)
|
||||
.setDefaultBgColor(backgroundColor)
|
||||
.setPressedBgColor(ColorUtils.darkenColor(backgroundColor))
|
||||
.create()
|
||||
|
||||
setTextColor(textColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package io.legato.kazusa.ui.widget.text
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
//import io.legado.app.lib.theme.bottomBackground
|
||||
|
||||
class AccentStrokeTextView(context: Context, attrs: AttributeSet) :
|
||||
AppCompatTextView(context, attrs) {
|
||||
|
||||
// private var radius = 3.dpToPx()
|
||||
// private val isBottomBackground: Boolean
|
||||
//
|
||||
// init {
|
||||
// val typedArray = context.obtainStyledAttributes(attrs, R.styleable.AccentStrokeTextView)
|
||||
// radius = typedArray.getDimensionPixelOffset(R.styleable.StrokeTextView_radius, radius)
|
||||
// isBottomBackground =
|
||||
// typedArray.getBoolean(R.styleable.StrokeTextView_isBottomBackground, false)
|
||||
// typedArray.recycle()
|
||||
// upStyle()
|
||||
// }
|
||||
//
|
||||
// private fun upStyle() {
|
||||
// val isLight = ColorUtils.isColorLight(context.bottomBackground)
|
||||
// val disableColor = if (isBottomBackground) {
|
||||
// if (isLight) {
|
||||
// context.getCompatColor(R.color.md_light_disabled)
|
||||
// } else {
|
||||
// context.getCompatColor(R.color.md_dark_disabled)
|
||||
// }
|
||||
// } else {
|
||||
// context.getCompatColor(R.color.disabled)
|
||||
// }
|
||||
// val accentColor = if (isInEditMode) {
|
||||
// context.getCompatColor(R.color.accent)
|
||||
// } else {
|
||||
// ThemeStore.accentColor(context)
|
||||
// }
|
||||
// background = Selector.shapeBuild()
|
||||
// .setCornerRadius(radius)
|
||||
// .setStrokeWidth(1.dpToPx())
|
||||
// .setDisabledStrokeColor(disableColor)
|
||||
// .setDefaultStrokeColor(accentColor)
|
||||
// .setPressedBgColor(context.getCompatColor(R.color.transparent30))
|
||||
// .create()
|
||||
// setTextColor(
|
||||
// Selector.colorBuild()
|
||||
// .setDefaultColor(accentColor)
|
||||
// .setDisabledColor(disableColor)
|
||||
// .create()
|
||||
// )
|
||||
// }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package io.legato.kazusa.ui.widget.text
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
//import io.legado.app.lib.theme.accentColor
|
||||
|
||||
class AccentTextView(context: Context, attrs: AttributeSet?) :
|
||||
AppCompatTextView(context, attrs) {
|
||||
|
||||
// init {
|
||||
// if (!isInEditMode) {
|
||||
// setTextColor(context.accentColor)
|
||||
// } else {
|
||||
// setTextColor(context.getCompatColor(R.color.accent))
|
||||
// }
|
||||
// }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package io.legato.kazusa.ui.widget.text
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.google.android.material.textfield.MaterialAutoCompleteTextView
|
||||
import io.legato.kazusa.R
|
||||
//import io.legado.app.lib.theme.accentColor
|
||||
import io.legato.kazusa.utils.gone
|
||||
import io.legato.kazusa.utils.visible
|
||||
|
||||
|
||||
@Suppress("unused")
|
||||
class AutoCompleteTextView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : MaterialAutoCompleteTextView(context, attrs) {
|
||||
|
||||
var delCallBack: ((value: String) -> Unit)? = null
|
||||
|
||||
init {
|
||||
//applyTint(context.accentColor)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
isLocalePreferredLineHeightForMinimumUsed = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun enoughToFilter(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent?): Boolean {
|
||||
if (event?.action == MotionEvent.ACTION_DOWN) {
|
||||
showDropDown()
|
||||
}
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
fun setFilterValues(values: List<String>?) {
|
||||
values?.let {
|
||||
setAdapter(MyAdapter(context, values))
|
||||
}
|
||||
}
|
||||
|
||||
fun setFilterValues(vararg value: String) {
|
||||
setAdapter(MyAdapter(context, value.toMutableList()))
|
||||
}
|
||||
|
||||
inner class MyAdapter(context: Context, values: List<String>) :
|
||||
ArrayAdapter<String>(context, android.R.layout.simple_dropdown_item_1line, values) {
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: LayoutInflater.from(context)
|
||||
.inflate(R.layout.item_1line_text_and_del, parent, false)
|
||||
val textView = view.findViewById<TextView>(R.id.text_view)
|
||||
textView.text = getItem(position)
|
||||
val ivDelete = view.findViewById<ImageView>(R.id.iv_delete)
|
||||
if (delCallBack != null) ivDelete.visible() else ivDelete.gone()
|
||||
ivDelete.setOnClickListener {
|
||||
getItem(position)?.let {
|
||||
remove(it)
|
||||
delCallBack?.invoke(it)
|
||||
showDropDown()
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package io.legato.kazusa.ui.widget.text
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ShapeDrawable
|
||||
import android.graphics.drawable.shapes.RoundRectShape
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.FrameLayout.LayoutParams
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import io.legato.kazusa.R
|
||||
//import io.legado.app.lib.theme.accentColor
|
||||
import io.legato.kazusa.utils.ColorUtils
|
||||
import io.legato.kazusa.utils.getCompatColor
|
||||
import io.legato.kazusa.utils.invisible
|
||||
import io.legato.kazusa.utils.visible
|
||||
|
||||
|
||||
/**
|
||||
* Created by milad heydari on 5/6/2016.
|
||||
*/
|
||||
@Suppress("MemberVisibilityCanBePrivate", "unused")
|
||||
class BadgeView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppCompatTextView(context, attrs) {
|
||||
|
||||
var isHideOnNull = true
|
||||
set(hideOnNull) {
|
||||
field = hideOnNull
|
||||
text = text
|
||||
}
|
||||
private var radius: Float = 0.toFloat()
|
||||
private var flatangle: Boolean
|
||||
|
||||
val badgeCount: Int?
|
||||
get() {
|
||||
if (text == null) {
|
||||
return null
|
||||
}
|
||||
val text = text.toString()
|
||||
return kotlin.runCatching {
|
||||
Integer.parseInt(text)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
var badgeGravity: Int
|
||||
get() {
|
||||
val params = layoutParams as LayoutParams
|
||||
return params.gravity
|
||||
}
|
||||
set(gravity) {
|
||||
val params = layoutParams as LayoutParams
|
||||
params.gravity = gravity
|
||||
layoutParams = params
|
||||
}
|
||||
|
||||
val badgeMargin: IntArray
|
||||
get() {
|
||||
val params = layoutParams as LayoutParams
|
||||
return intArrayOf(
|
||||
params.leftMargin,
|
||||
params.topMargin,
|
||||
params.rightMargin,
|
||||
params.bottomMargin
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.BadgeView)
|
||||
val radios =
|
||||
typedArray.getDimensionPixelOffset(R.styleable.BadgeView_radius, 8)
|
||||
flatangle =
|
||||
typedArray.getBoolean(R.styleable.BadgeView_up_flat_angle, false)
|
||||
typedArray.recycle()
|
||||
|
||||
if (layoutParams !is LayoutParams) {
|
||||
val layoutParams = LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.CENTER
|
||||
)
|
||||
setLayoutParams(layoutParams)
|
||||
}
|
||||
|
||||
//setTypeface(Typeface.DEFAULT_BOLD);
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
|
||||
setPadding(dip2Px(5f), dip2Px(1f), dip2Px(5f), dip2Px(1f))
|
||||
radius = radios.toFloat()
|
||||
|
||||
// set default background
|
||||
//setBackground(radius, context.accentColor)
|
||||
|
||||
gravity = Gravity.CENTER
|
||||
|
||||
// default values
|
||||
isHideOnNull = true
|
||||
setBadgeCount(0)
|
||||
minWidth = dip2Px(16f)
|
||||
minHeight = dip2Px(16f)
|
||||
}
|
||||
|
||||
override fun setBackgroundColor(color: Int) {
|
||||
val background = background
|
||||
if (background is ShapeDrawable && background.paint.color == color) {
|
||||
return
|
||||
}
|
||||
setBackground(radius, color)
|
||||
}
|
||||
|
||||
fun setBackground(dipRadius: Float, badgeColor: Int) {
|
||||
val radius = dip2Px(dipRadius).toFloat()
|
||||
val radiusArray =
|
||||
floatArrayOf(radius, radius, radius, radius, radius, radius, radius, radius)
|
||||
if (flatangle) {
|
||||
radiusArray.fill(0f, 0, 3)
|
||||
}
|
||||
|
||||
val roundRect = RoundRectShape(radiusArray, null, null)
|
||||
val bgDrawable = ShapeDrawable(roundRect)
|
||||
bgDrawable.paint.color = badgeColor
|
||||
background = bgDrawable
|
||||
setTextColor(
|
||||
if (ColorUtils.isColorLight(badgeColor)) {
|
||||
Color.BLACK
|
||||
} else {
|
||||
Color.WHITE
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see android.widget.TextView.setText
|
||||
*/
|
||||
override fun setText(text: CharSequence, type: BufferType) {
|
||||
if (isHideOnNull && TextUtils.isEmpty(text)) {
|
||||
invisible()
|
||||
} else {
|
||||
visible()
|
||||
}
|
||||
super.setText(text, type)
|
||||
}
|
||||
|
||||
fun setBadgeCount(count: Int) {
|
||||
text = if (count == 0) "" else count.toString()
|
||||
}
|
||||
|
||||
fun setHighlight(highlight: Boolean) {
|
||||
if (highlight) {
|
||||
//setBackgroundColor(context.accentColor)
|
||||
} else {
|
||||
setBackgroundColor(context.getCompatColor(R.color.darker_gray))
|
||||
}
|
||||
}
|
||||
|
||||
fun setBadgeMargin(dipMargin: Int) {
|
||||
setBadgeMargin(dipMargin, dipMargin, dipMargin, dipMargin)
|
||||
}
|
||||
|
||||
fun setBadgeMargin(
|
||||
leftDipMargin: Int,
|
||||
topDipMargin: Int,
|
||||
rightDipMargin: Int,
|
||||
bottomDipMargin: Int
|
||||
) {
|
||||
val params = layoutParams as LayoutParams
|
||||
params.leftMargin = dip2Px(leftDipMargin.toFloat())
|
||||
params.topMargin = dip2Px(topDipMargin.toFloat())
|
||||
params.rightMargin = dip2Px(rightDipMargin.toFloat())
|
||||
params.bottomMargin = dip2Px(bottomDipMargin.toFloat())
|
||||
layoutParams = params
|
||||
}
|
||||
|
||||
fun incrementBadgeCount(increment: Int) {
|
||||
val count = badgeCount
|
||||
if (count == null) {
|
||||
setBadgeCount(increment)
|
||||
} else {
|
||||
setBadgeCount(increment + count)
|
||||
}
|
||||
}
|
||||
|
||||
fun decrementBadgeCount(decrement: Int) {
|
||||
incrementBadgeCount(-decrement)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the BadgeView to the target view
|
||||
* @param target the view to attach the BadgeView
|
||||
*/
|
||||
fun setTargetView(target: View?) {
|
||||
if (parent != null) {
|
||||
(parent as ViewGroup).removeView(this)
|
||||
}
|
||||
|
||||
if (target == null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (target.parent is FrameLayout) {
|
||||
(target.parent as FrameLayout).addView(this)
|
||||
|
||||
} else if (target.parent is ViewGroup) {
|
||||
// use a new FrameLayout container for adding badge
|
||||
val parentContainer = target.parent as ViewGroup
|
||||
val groupIndex = parentContainer.indexOfChild(target)
|
||||
parentContainer.removeView(target)
|
||||
|
||||
val badgeContainer = FrameLayout(context)
|
||||
val parentLayoutParams = target.layoutParams
|
||||
|
||||
badgeContainer.layoutParams = parentLayoutParams
|
||||
target.layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
parentContainer.addView(badgeContainer, groupIndex, parentLayoutParams)
|
||||
badgeContainer.addView(target)
|
||||
|
||||
badgeContainer.addView(this)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* converts dip to px
|
||||
*/
|
||||
private fun dip2Px(dip: Float): Int {
|
||||
return (dip * context.resources.displayMetrics.density + 0.5f).toInt()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package io.legato.kazusa.ui.widget.text
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.IntDef
|
||||
import io.legato.kazusa.R
|
||||
//import io.legado.app.lib.theme.accentColor
|
||||
|
||||
/**
|
||||
* 斜角标签
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class BevelLabelView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs) {
|
||||
|
||||
companion object {
|
||||
const val MODE_LEFT_TOP = 0
|
||||
const val MODE_RIGHT_TOP = 1
|
||||
const val MODE_LEFT_BOTTOM = 2
|
||||
const val MODE_RIGHT_BOTTOM = 3
|
||||
const val MODE_LEFT_TOP_FILL = 4
|
||||
const val MODE_RIGHT_TOP_FILL = 5
|
||||
const val MODE_LEFT_BOTTOM_FILL = 6
|
||||
const val MODE_RIGHT_BOTTOM_FILL = 7
|
||||
}
|
||||
|
||||
//private var mBgColor: Int
|
||||
private var mText: String
|
||||
private var mTextSize: Int
|
||||
private var mTextColor: Int
|
||||
private var mLength: Int
|
||||
private var mCorner: Int
|
||||
private var mMode: Int
|
||||
private var mPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private var path: Path = Path()
|
||||
private var mWidth = 0
|
||||
private var mHeight: Int = 0
|
||||
private var mRotate = 45 //因为默认模式是1,所以这时是45度
|
||||
|
||||
private var mX: Int = 0
|
||||
private var mY: Int = 0
|
||||
|
||||
init {
|
||||
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.BevelLabelView)
|
||||
// mBgColor = typedArray.getColor(
|
||||
// R.styleable.BevelLabelView_label_bg_color,
|
||||
// context.accentColor
|
||||
// ) //默认红色
|
||||
mText = typedArray.getString(R.styleable.BevelLabelView_label_text) ?: ""
|
||||
mTextSize =
|
||||
typedArray.getDimensionPixelOffset(
|
||||
R.styleable.BevelLabelView_label_text_size,
|
||||
sp2px(11)
|
||||
)
|
||||
mTextColor = typedArray.getColor(R.styleable.BevelLabelView_label_text_color, Color.WHITE)
|
||||
mLength =
|
||||
typedArray.getDimensionPixelOffset(R.styleable.BevelLabelView_label_length, dip2px(40))
|
||||
mCorner = typedArray.getDimensionPixelOffset(R.styleable.BevelLabelView_label_corner, 0)
|
||||
mMode = typedArray.getInt(R.styleable.BevelLabelView_label_mode, 1)
|
||||
mPaint.isAntiAlias = true
|
||||
typedArray.recycle()
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
mWidth = MeasureSpec.getSize(widthMeasureSpec)
|
||||
mHeight = mWidth
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
//mPaint.color = mBgColor
|
||||
drawBackgroundText(canvas)
|
||||
}
|
||||
|
||||
fun setMode(@BevelLabelMode mode: Int) {
|
||||
mMode = mode
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setTextColor(@ColorInt color: Int) {
|
||||
mTextColor = color
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setBgColor(@ColorInt color: Int) {
|
||||
//mBgColor = color
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun drawBackgroundText(canvas: Canvas) {
|
||||
check(mWidth == mHeight) {
|
||||
"width must equal to height" //标签view 是一个正方形,
|
||||
}
|
||||
when (mMode) {
|
||||
MODE_LEFT_TOP -> {
|
||||
mCorner = 0 //没有铺满的时候mCorner要归零;
|
||||
leftTopMeasure()
|
||||
getLeftTop()
|
||||
}
|
||||
MODE_RIGHT_TOP -> {
|
||||
mCorner = 0
|
||||
rightTopMeasure()
|
||||
getRightTop()
|
||||
}
|
||||
MODE_LEFT_BOTTOM -> {
|
||||
mCorner = 0
|
||||
leftBottomMeasure()
|
||||
getLeftBottom()
|
||||
}
|
||||
MODE_RIGHT_BOTTOM -> {
|
||||
mCorner = 0
|
||||
rightBottomMeasure()
|
||||
getRightBottom()
|
||||
}
|
||||
MODE_LEFT_TOP_FILL -> {
|
||||
leftTopMeasure()
|
||||
getLeftTopFill()
|
||||
if (mCorner != 0) {
|
||||
canvas.drawPath(path, mPaint)
|
||||
getLeftTop()
|
||||
}
|
||||
}
|
||||
MODE_RIGHT_TOP_FILL -> {
|
||||
rightTopMeasure()
|
||||
getRightTopFill()
|
||||
if (mCorner != 0) {
|
||||
canvas.drawPath(path, mPaint)
|
||||
getRightTop()
|
||||
}
|
||||
}
|
||||
MODE_LEFT_BOTTOM_FILL -> {
|
||||
leftBottomMeasure()
|
||||
getLeftBottomFill()
|
||||
if (mCorner != 0) {
|
||||
canvas.drawPath(path, mPaint)
|
||||
getLeftBottom()
|
||||
}
|
||||
}
|
||||
MODE_RIGHT_BOTTOM_FILL -> {
|
||||
rightBottomMeasure()
|
||||
getRightBottomFill()
|
||||
if (mCorner != 0) {
|
||||
canvas.drawPath(path, mPaint)
|
||||
getRightBottom()
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
canvas.drawPath(path, mPaint)
|
||||
mPaint.textSize = mTextSize.toFloat()
|
||||
mPaint.textAlign = Paint.Align.CENTER
|
||||
mPaint.color = mTextColor
|
||||
canvas.translate(mX.toFloat(), mY.toFloat())
|
||||
canvas.rotate(mRotate.toFloat())
|
||||
val baseLineY = (-(mPaint.descent() + mPaint.ascent())).toInt() / 2 //基线中间点的y轴计算公式
|
||||
canvas.drawText(mText, 0f, baseLineY.toFloat(), mPaint)
|
||||
}
|
||||
|
||||
private fun rightBottomMeasure() {
|
||||
mRotate = -45
|
||||
mX = mWidth / 2 + mLength / 4
|
||||
mY = mX
|
||||
}
|
||||
|
||||
private fun leftBottomMeasure() {
|
||||
mRotate = 45
|
||||
mX = mWidth / 2 - mLength / 4
|
||||
mY = mHeight / 2 + mLength / 4
|
||||
}
|
||||
|
||||
private fun rightTopMeasure() {
|
||||
mRotate = 45
|
||||
mX = mWidth / 2 + mLength / 4
|
||||
mY = mHeight / 2 - mLength / 4
|
||||
}
|
||||
|
||||
private fun leftTopMeasure() {
|
||||
mRotate = -45
|
||||
mX = mWidth / 2 - mLength / 4
|
||||
mY = mX
|
||||
}
|
||||
|
||||
//左上角铺满
|
||||
private fun getLeftTopFill() {
|
||||
if (mCorner != 0) {
|
||||
path.addRoundRect(
|
||||
0f,
|
||||
0f,
|
||||
(mWidth / 2).toFloat(),
|
||||
(mHeight / 2).toFloat(),
|
||||
floatArrayOf(mCorner.toFloat(), mCorner.toFloat(), 0f, 0f, 0f, 0f, 0f, 0f),
|
||||
Path.Direction.CW
|
||||
)
|
||||
} else {
|
||||
path.moveTo(0f, 0f)
|
||||
path.lineTo(mWidth.toFloat(), 0f)
|
||||
path.lineTo(0f, mHeight.toFloat())
|
||||
path.close()
|
||||
}
|
||||
}
|
||||
|
||||
//左上角不铺满
|
||||
private fun getLeftTop() {
|
||||
path.moveTo(if (mCorner != 0) mCorner.toFloat() else (mWidth - mLength).toFloat(), 0f)
|
||||
path.lineTo(mWidth.toFloat(), 0f)
|
||||
path.lineTo(0f, mHeight.toFloat())
|
||||
path.lineTo(0f, if (mCorner != 0) mCorner.toFloat() else (mHeight - mLength).toFloat())
|
||||
path.close()
|
||||
}
|
||||
|
||||
//左下角铺满
|
||||
private fun getLeftBottomFill() {
|
||||
if (mCorner != 0) {
|
||||
path.addRoundRect(
|
||||
0f,
|
||||
(mHeight / 2).toFloat(),
|
||||
(mWidth / 2).toFloat(),
|
||||
mHeight.toFloat(),
|
||||
floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, mCorner.toFloat(), mCorner.toFloat()),
|
||||
Path.Direction.CW
|
||||
)
|
||||
} else {
|
||||
path.moveTo(0f, 0f)
|
||||
path.lineTo(mWidth.toFloat(), mHeight.toFloat())
|
||||
path.lineTo(0f, mHeight.toFloat())
|
||||
path.close()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//左下角不铺满
|
||||
private fun getLeftBottom() {
|
||||
path.moveTo(0f, 0f)
|
||||
path.lineTo(mWidth.toFloat(), mHeight.toFloat())
|
||||
path.lineTo(
|
||||
if (mCorner != 0) mCorner.toFloat() else (mWidth - mLength).toFloat(),
|
||||
mHeight.toFloat()
|
||||
)
|
||||
path.lineTo(0f, if (mCorner != 0) (mHeight - mCorner).toFloat() else mLength.toFloat())
|
||||
path.close()
|
||||
}
|
||||
|
||||
//右上角铺满
|
||||
private fun getRightTopFill() {
|
||||
if (mCorner != 0) {
|
||||
path.addRoundRect(
|
||||
(mWidth / 2).toFloat(),
|
||||
0f,
|
||||
mWidth.toFloat(),
|
||||
(mHeight / 2).toFloat(),
|
||||
floatArrayOf(0f, 0f, mCorner.toFloat(), mCorner.toFloat(), 0f, 0f, 0f, 0f),
|
||||
Path.Direction.CW
|
||||
)
|
||||
} else {
|
||||
path.moveTo(0f, 0f)
|
||||
path.lineTo(mWidth.toFloat(), 0f)
|
||||
path.lineTo(mWidth.toFloat(), mHeight.toFloat())
|
||||
path.close()
|
||||
}
|
||||
}
|
||||
|
||||
//右上角不铺满
|
||||
private fun getRightTop() {
|
||||
path.moveTo(0f, 0f)
|
||||
path.lineTo(if (mCorner != 0) (mWidth - mCorner).toFloat() else mLength.toFloat(), 0f)
|
||||
path.lineTo(
|
||||
mWidth.toFloat(),
|
||||
if (mCorner != 0) mCorner.toFloat() else (mHeight - mLength).toFloat()
|
||||
)
|
||||
path.lineTo(mWidth.toFloat(), mHeight.toFloat())
|
||||
path.close()
|
||||
}
|
||||
|
||||
//右下角铺满
|
||||
private fun getRightBottomFill() {
|
||||
if (mCorner != 0) {
|
||||
path.addRoundRect(
|
||||
(mWidth / 2).toFloat(),
|
||||
(mHeight / 2).toFloat(),
|
||||
mWidth.toFloat(),
|
||||
mHeight.toFloat(),
|
||||
floatArrayOf(0f, 0f, 0f, 0f, mCorner.toFloat(), mCorner.toFloat(), 0f, 0f),
|
||||
Path.Direction.CW
|
||||
)
|
||||
} else {
|
||||
path.moveTo(mWidth.toFloat(), 0f)
|
||||
path.lineTo(mWidth.toFloat(), mHeight.toFloat())
|
||||
path.lineTo(0f, mHeight.toFloat())
|
||||
path.close()
|
||||
}
|
||||
}
|
||||
|
||||
//右下角不铺满
|
||||
private fun getRightBottom() {
|
||||
path.moveTo(mWidth.toFloat(), 0f)
|
||||
path.lineTo(
|
||||
mWidth.toFloat(),
|
||||
if (mCorner != 0) (mHeight - mCorner).toFloat() else mLength.toFloat()
|
||||
)
|
||||
path.lineTo(
|
||||
if (mCorner != 0) (mWidth - mCorner).toFloat() else mLength.toFloat(),
|
||||
mHeight.toFloat()
|
||||
)
|
||||
path.lineTo(0f, mHeight.toFloat())
|
||||
path.close()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param sp 转换大小
|
||||
*/
|
||||
@Suppress("SameParameterValue")
|
||||
private fun sp2px(sp: Int): Int {
|
||||
return TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_SP,
|
||||
sp.toFloat(),
|
||||
resources.displayMetrics
|
||||
)
|
||||
.toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param dip 转换大小
|
||||
*/
|
||||
@Suppress("SameParameterValue")
|
||||
private fun dip2px(dip: Int): Int {
|
||||
return TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
dip.toFloat(),
|
||||
resources.displayMetrics
|
||||
)
|
||||
.toInt()
|
||||
}
|
||||
|
||||
@Target(AnnotationTarget.VALUE_PARAMETER)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
@IntDef(
|
||||
MODE_LEFT_BOTTOM, MODE_LEFT_BOTTOM_FILL, MODE_LEFT_TOP, MODE_LEFT_TOP_FILL,
|
||||
MODE_RIGHT_BOTTOM, MODE_RIGHT_BOTTOM_FILL, MODE_RIGHT_TOP, MODE_RIGHT_TOP_FILL
|
||||
)
|
||||
annotation class BevelLabelMode
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package io.legato.kazusa.ui.widget.text
|
||||
|
||||
import splitties.init.appCtx
|
||||
|
||||
data class EditEntity(
|
||||
var key: String,
|
||||
var value: String?,
|
||||
var hint: String,
|
||||
val viewType: Int = 0
|
||||
) {
|
||||
|
||||
constructor(
|
||||
key: String,
|
||||
value: String?,
|
||||
hint: Int,
|
||||
viewType: Int = 0
|
||||
) : this(
|
||||
key,
|
||||
value,
|
||||
appCtx.getString(hint),
|
||||
viewType
|
||||
)
|
||||
|
||||
@Suppress("unused")
|
||||
object ViewType {
|
||||
|
||||
const val checkBox = 1
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package io.legato.kazusa.ui.widget.text
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
|
||||
class MultilineTextView(context: Context, attrs: AttributeSet?) :
|
||||
AppCompatTextView(context, attrs) {
|
||||
|
||||
init {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
isFallbackLineSpacing = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
|
||||
calculateLines(heightSize)
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
}
|
||||
|
||||
private fun calculateLines(measuredHeight: Int) {
|
||||
val lHeight = lineHeight
|
||||
val lines = measuredHeight / lHeight
|
||||
setLines(lines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package io.legato.kazusa.ui.widget.text
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import io.legato.kazusa.lib.theme.ThemeStore
|
||||
|
||||
/**
|
||||
* @author Aidan Follestad (afollestad)
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class PrimaryTextView(context: Context, attrs: AttributeSet) :
|
||||
AppCompatTextView(context, attrs) {
|
||||
|
||||
init {
|
||||
setTextColor(ThemeStore.textColorPrimary(context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package io.legato.kazusa.ui.widget.text
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.VelocityTracker
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.animation.Interpolator
|
||||
import android.widget.OverScroller
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.google.android.material.textfield.MaterialAutoCompleteTextView
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 嵌套惯性滚动 MultiAutoCompleteTextView
|
||||
*/
|
||||
open class ScrollMultiAutoCompleteTextView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : MaterialAutoCompleteTextView(context, attrs) {
|
||||
|
||||
//是否到顶或者到底的标志
|
||||
private var disallowIntercept = true
|
||||
|
||||
private val scrollStateIdle = 0
|
||||
private val scrollStateDragging = 1
|
||||
val scrollStateSettling = 2
|
||||
|
||||
private val mViewFling: ViewFling by lazy { ViewFling() }
|
||||
private val velocityTracker: VelocityTracker by lazy { VelocityTracker.obtain() }
|
||||
private var mScrollState = scrollStateIdle
|
||||
private var mLastTouchY: Int = 0
|
||||
private var mTouchSlop: Int = 0
|
||||
private var mMinFlingVelocity: Int = 0
|
||||
private var mMaxFlingVelocity: Int = 0
|
||||
|
||||
//滑动距离的最大边界
|
||||
private var mOffsetHeight: Int = 0
|
||||
|
||||
//f(x) = (x-1)^5 + 1
|
||||
private val sQuinticInterpolator = Interpolator {
|
||||
var t = it
|
||||
t -= 1.0f
|
||||
t * t * t * t * t + 1.0f
|
||||
}
|
||||
|
||||
private val gestureDetector = GestureDetector(context,
|
||||
object : GestureDetector.SimpleOnGestureListener() {
|
||||
|
||||
override fun onDown(e: MotionEvent): Boolean {
|
||||
disallowIntercept = true
|
||||
return super.onDown(e)
|
||||
}
|
||||
|
||||
override fun onScroll(
|
||||
e1: MotionEvent?,
|
||||
e2: MotionEvent,
|
||||
distanceX: Float,
|
||||
distanceY: Float
|
||||
): Boolean {
|
||||
val y = scrollY + distanceY
|
||||
if (y < 0 || y > mOffsetHeight) {
|
||||
disallowIntercept = false
|
||||
//这里触发父布局或祖父布局的滑动事件
|
||||
parent.requestDisallowInterceptTouchEvent(false)
|
||||
} else {
|
||||
disallowIntercept = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
init {
|
||||
val vc = ViewConfiguration.get(context)
|
||||
mTouchSlop = vc.scaledTouchSlop
|
||||
mMinFlingVelocity = vc.scaledMinimumFlingVelocity
|
||||
mMaxFlingVelocity = vc.scaledMaximumFlingVelocity
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
isLocalePreferredLineHeightForMinimumUsed = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
initOffsetHeight()
|
||||
}
|
||||
|
||||
override fun onTextChanged(
|
||||
text: CharSequence,
|
||||
start: Int,
|
||||
lengthBefore: Int,
|
||||
lengthAfter: Int
|
||||
) {
|
||||
super.onTextChanged(text, start, lengthBefore, lengthAfter)
|
||||
initOffsetHeight()
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||
if (lineCount > maxLines) {
|
||||
gestureDetector.onTouchEvent(event)
|
||||
}
|
||||
velocityTracker.addMovement(event)
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
setScrollState(scrollStateIdle)
|
||||
mLastTouchY = (event.y + 0.5f).toInt()
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val y = (event.y + 0.5f).toInt()
|
||||
var dy = mLastTouchY - y
|
||||
if (mScrollState != scrollStateDragging) {
|
||||
var startScroll = false
|
||||
|
||||
if (abs(dy) > mTouchSlop) {
|
||||
if (dy > 0) {
|
||||
dy -= mTouchSlop
|
||||
} else {
|
||||
dy += mTouchSlop
|
||||
}
|
||||
startScroll = true
|
||||
}
|
||||
if (startScroll) {
|
||||
setScrollState(scrollStateDragging)
|
||||
}
|
||||
}
|
||||
if (mScrollState == scrollStateDragging) {
|
||||
mLastTouchY = y
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
velocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity.toFloat())
|
||||
val yVelocity = velocityTracker.yVelocity
|
||||
if (abs(yVelocity) > mMinFlingVelocity) {
|
||||
mViewFling.fling(-yVelocity.toInt())
|
||||
} else {
|
||||
setScrollState(scrollStateIdle)
|
||||
}
|
||||
resetTouch()
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
resetTouch()
|
||||
}
|
||||
}
|
||||
return super.dispatchTouchEvent(event)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val result = super.onTouchEvent(event)
|
||||
//如果是需要拦截,则再拦截,这个方法会在onScrollChanged方法之后再调用一次
|
||||
if (disallowIntercept && lineCount > maxLines) {
|
||||
parent.requestDisallowInterceptTouchEvent(true)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun scrollTo(x: Int, y: Int) {
|
||||
super.scrollTo(x, min(y, mOffsetHeight))
|
||||
}
|
||||
|
||||
private fun initOffsetHeight() {
|
||||
val mLayoutHeight: Int
|
||||
|
||||
//获得内容面板
|
||||
val mLayout = layout ?: return
|
||||
//获得内容面板的高度
|
||||
mLayoutHeight = mLayout.height
|
||||
//获取上内边距
|
||||
val paddingTop: Int = totalPaddingTop
|
||||
//获取下内边距
|
||||
val paddingBottom: Int = totalPaddingBottom
|
||||
|
||||
//获得控件的实际高度
|
||||
val mHeight: Int = measuredHeight
|
||||
|
||||
//计算滑动距离的边界
|
||||
mOffsetHeight = mLayoutHeight + paddingTop + paddingBottom - mHeight
|
||||
if (mOffsetHeight <= 0) {
|
||||
scrollTo(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetTouch() {
|
||||
velocityTracker.clear()
|
||||
}
|
||||
|
||||
private fun setScrollState(state: Int) {
|
||||
if (state == mScrollState) {
|
||||
return
|
||||
}
|
||||
mScrollState = state
|
||||
if (state != scrollStateSettling) {
|
||||
mViewFling.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 惯性滚动
|
||||
*/
|
||||
private inner class ViewFling : Runnable {
|
||||
|
||||
private var mLastFlingY = 0
|
||||
private val mScroller: OverScroller = OverScroller(context, sQuinticInterpolator)
|
||||
private var mEatRunOnAnimationRequest = false
|
||||
private var mReSchedulePostAnimationCallback = false
|
||||
|
||||
override fun run() {
|
||||
disableRunOnAnimationRequests()
|
||||
val scroller = mScroller
|
||||
if (scroller.computeScrollOffset()) {
|
||||
val y = scroller.currY
|
||||
val dy = y - mLastFlingY
|
||||
mLastFlingY = y
|
||||
if (dy < 0 && scrollY > 0) {
|
||||
scrollBy(0, max(dy, -scrollY))
|
||||
} else if (dy > 0 && scrollY < mOffsetHeight) {
|
||||
scrollBy(0, min(dy, mOffsetHeight - scrollY))
|
||||
}
|
||||
postOnAnimation()
|
||||
}
|
||||
enableRunOnAnimationRequests()
|
||||
}
|
||||
|
||||
fun fling(velocityY: Int) {
|
||||
mLastFlingY = 0
|
||||
setScrollState(scrollStateSettling)
|
||||
mScroller.fling(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
velocityY,
|
||||
Integer.MIN_VALUE,
|
||||
Integer.MAX_VALUE,
|
||||
Integer.MIN_VALUE,
|
||||
Integer.MAX_VALUE
|
||||
)
|
||||
postOnAnimation()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
removeCallbacks(this)
|
||||
mScroller.abortAnimation()
|
||||
}
|
||||
|
||||
private fun disableRunOnAnimationRequests() {
|
||||
mReSchedulePostAnimationCallback = false
|
||||
mEatRunOnAnimationRequest = true
|
||||
}
|
||||
|
||||
private fun enableRunOnAnimationRequests() {
|
||||
mEatRunOnAnimationRequest = false
|
||||
if (mReSchedulePostAnimationCallback) {
|
||||
postOnAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun postOnAnimation() {
|
||||
if (mEatRunOnAnimationRequest) {
|
||||
mReSchedulePostAnimationCallback = true
|
||||
} else {
|
||||
removeCallbacks(this)
|
||||
ViewCompat.postOnAnimation(this@ScrollMultiAutoCompleteTextView, this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package io.legato.kazusa.ui.widget.text
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.util.AttributeSet
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.VelocityTracker
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.animation.Interpolator
|
||||
import android.widget.OverScroller
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.google.android.material.textview.MaterialTextView
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
/**
|
||||
* 嵌套惯性滚动 TextView
|
||||
*/
|
||||
class ScrollTextView(context: Context, attrs: AttributeSet?) :
|
||||
MaterialTextView(context, attrs) {
|
||||
|
||||
//是否到顶或者到底的标志
|
||||
private var disallowIntercept = true
|
||||
|
||||
private val scrollStateIdle = 0
|
||||
private val scrollStateDragging = 1
|
||||
val scrollStateSettling = 2
|
||||
|
||||
private val mViewFling: ViewFling by lazy { ViewFling() }
|
||||
private val velocityTracker: VelocityTracker by lazy { VelocityTracker.obtain() }
|
||||
private var mScrollState = scrollStateIdle
|
||||
private var mLastTouchY: Int = 0
|
||||
private var mTouchSlop: Int = 0
|
||||
private var mMinFlingVelocity: Int = 0
|
||||
private var mMaxFlingVelocity: Int = 0
|
||||
|
||||
//滑动距离的最大边界
|
||||
private var mOffsetHeight: Int = 0
|
||||
|
||||
//f(x) = (x-1)^5 + 1
|
||||
private val sQuinticInterpolator = Interpolator {
|
||||
var t = it
|
||||
t -= 1.0f
|
||||
t * t * t * t * t + 1.0f
|
||||
}
|
||||
|
||||
private val gestureDetector = GestureDetector(context,
|
||||
object : GestureDetector.SimpleOnGestureListener() {
|
||||
|
||||
override fun onDown(e: MotionEvent): Boolean {
|
||||
disallowIntercept = true
|
||||
return super.onDown(e)
|
||||
}
|
||||
|
||||
override fun onScroll(
|
||||
e1: MotionEvent?,
|
||||
e2: MotionEvent,
|
||||
distanceX: Float,
|
||||
distanceY: Float
|
||||
): Boolean {
|
||||
val y = scrollY + distanceY
|
||||
if (y < 0 || y > mOffsetHeight) {
|
||||
disallowIntercept = false
|
||||
//这里触发父布局或祖父布局的滑动事件
|
||||
parent.requestDisallowInterceptTouchEvent(false)
|
||||
} else {
|
||||
disallowIntercept = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
init {
|
||||
val vc = ViewConfiguration.get(context)
|
||||
mTouchSlop = vc.scaledTouchSlop
|
||||
mMinFlingVelocity = vc.scaledMinimumFlingVelocity
|
||||
mMaxFlingVelocity = vc.scaledMaximumFlingVelocity
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
initOffsetHeight()
|
||||
}
|
||||
|
||||
override fun onTextChanged(
|
||||
text: CharSequence,
|
||||
start: Int,
|
||||
lengthBefore: Int,
|
||||
lengthAfter: Int
|
||||
) {
|
||||
super.onTextChanged(text, start, lengthBefore, lengthAfter)
|
||||
initOffsetHeight()
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||
if (lineCount > maxLines) {
|
||||
gestureDetector.onTouchEvent(event)
|
||||
}
|
||||
velocityTracker.addMovement(event)
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
setScrollState(scrollStateIdle)
|
||||
mLastTouchY = (event.y + 0.5f).toInt()
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val y = (event.y + 0.5f).toInt()
|
||||
var dy = mLastTouchY - y
|
||||
if (mScrollState != scrollStateDragging) {
|
||||
var startScroll = false
|
||||
|
||||
if (abs(dy) > mTouchSlop) {
|
||||
if (dy > 0) {
|
||||
dy -= mTouchSlop
|
||||
} else {
|
||||
dy += mTouchSlop
|
||||
}
|
||||
startScroll = true
|
||||
}
|
||||
if (startScroll) {
|
||||
setScrollState(scrollStateDragging)
|
||||
}
|
||||
}
|
||||
if (mScrollState == scrollStateDragging) {
|
||||
mLastTouchY = y
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
velocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity.toFloat())
|
||||
val yVelocity = velocityTracker.yVelocity
|
||||
if (abs(yVelocity) > mMinFlingVelocity) {
|
||||
mViewFling.fling(-yVelocity.toInt())
|
||||
} else {
|
||||
setScrollState(scrollStateIdle)
|
||||
}
|
||||
resetTouch()
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
resetTouch()
|
||||
}
|
||||
}
|
||||
return super.dispatchTouchEvent(event)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val result = super.onTouchEvent(event)
|
||||
//如果是需要拦截,则再拦截,这个方法会在onScrollChanged方法之后再调用一次
|
||||
if (disallowIntercept && lineCount > maxLines) {
|
||||
parent.requestDisallowInterceptTouchEvent(true)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun scrollTo(x: Int, y: Int) {
|
||||
super.scrollTo(x, min(y, mOffsetHeight))
|
||||
}
|
||||
|
||||
private fun initOffsetHeight() {
|
||||
val mLayoutHeight: Int
|
||||
|
||||
//获得内容面板
|
||||
val mLayout = layout ?: return
|
||||
//获得内容面板的高度
|
||||
mLayoutHeight = mLayout.height
|
||||
//获取上内边距
|
||||
val paddingTop: Int = totalPaddingTop
|
||||
//获取下内边距
|
||||
val paddingBottom: Int = totalPaddingBottom
|
||||
|
||||
//获得控件的实际高度
|
||||
val mHeight: Int = measuredHeight
|
||||
|
||||
//计算滑动距离的边界
|
||||
mOffsetHeight = mLayoutHeight + paddingTop + paddingBottom - mHeight
|
||||
if (mOffsetHeight <= 0) {
|
||||
scrollTo(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetTouch() {
|
||||
velocityTracker.clear()
|
||||
}
|
||||
|
||||
private fun setScrollState(state: Int) {
|
||||
if (state == mScrollState) {
|
||||
return
|
||||
}
|
||||
mScrollState = state
|
||||
if (state != scrollStateSettling) {
|
||||
mViewFling.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 惯性滚动
|
||||
*/
|
||||
private inner class ViewFling : Runnable {
|
||||
|
||||
private var mLastFlingY = 0
|
||||
private val mScroller: OverScroller = OverScroller(context, sQuinticInterpolator)
|
||||
private var mEatRunOnAnimationRequest = false
|
||||
private var mReSchedulePostAnimationCallback = false
|
||||
|
||||
override fun run() {
|
||||
disableRunOnAnimationRequests()
|
||||
val scroller = mScroller
|
||||
if (scroller.computeScrollOffset()) {
|
||||
val y = scroller.currY
|
||||
val dy = y - mLastFlingY
|
||||
mLastFlingY = y
|
||||
if (dy < 0 && scrollY > 0) {
|
||||
scrollBy(0, max(dy, -scrollY))
|
||||
} else if (dy > 0 && scrollY < mOffsetHeight) {
|
||||
scrollBy(0, min(dy, mOffsetHeight - scrollY))
|
||||
}
|
||||
postOnAnimation()
|
||||
}
|
||||
enableRunOnAnimationRequests()
|
||||
}
|
||||
|
||||
fun fling(velocityY: Int) {
|
||||
mLastFlingY = 0
|
||||
setScrollState(scrollStateSettling)
|
||||
mScroller.fling(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
velocityY,
|
||||
Integer.MIN_VALUE,
|
||||
Integer.MAX_VALUE,
|
||||
Integer.MIN_VALUE,
|
||||
Integer.MAX_VALUE
|
||||
)
|
||||
postOnAnimation()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
removeCallbacks(this)
|
||||
mScroller.abortAnimation()
|
||||
}
|
||||
|
||||
private fun disableRunOnAnimationRequests() {
|
||||
mReSchedulePostAnimationCallback = false
|
||||
mEatRunOnAnimationRequest = true
|
||||
}
|
||||
|
||||
private fun enableRunOnAnimationRequests() {
|
||||
mEatRunOnAnimationRequest = false
|
||||
if (mReSchedulePostAnimationCallback) {
|
||||
postOnAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun postOnAnimation() {
|
||||
if (mEatRunOnAnimationRequest) {
|
||||
mReSchedulePostAnimationCallback = true
|
||||
} else {
|
||||
removeCallbacks(this)
|
||||
ViewCompat.postOnAnimation(this@ScrollTextView, this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package io.legato.kazusa.ui.widget.text
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
//import io.legado.app.lib.theme.secondaryTextColor
|
||||
|
||||
/**
|
||||
* @author Aidan Follestad (afollestad)
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class SecondaryTextView(context: Context, attrs: AttributeSet) :
|
||||
AppCompatTextView(context, attrs) {
|
||||
|
||||
init {
|
||||
//setTextColor(context.secondaryTextColor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package io.legato.kazusa.ui.widget.text
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.lib.theme.*
|
||||
import io.legato.kazusa.utils.dpToPx
|
||||
import io.legato.kazusa.utils.getCompatColor
|
||||
|
||||
@Suppress("unused")
|
||||
open class StrokeTextView(context: Context, attrs: AttributeSet?) :
|
||||
AppCompatTextView(context, attrs) {
|
||||
|
||||
private var radius = 1.dpToPx()
|
||||
private val isBottomBackground: Boolean
|
||||
|
||||
init {
|
||||
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.StrokeTextView)
|
||||
radius = typedArray.getDimensionPixelOffset(R.styleable.StrokeTextView_radius, radius)
|
||||
isBottomBackground =
|
||||
typedArray.getBoolean(R.styleable.StrokeTextView_isBottomBackground, false)
|
||||
typedArray.recycle()
|
||||
upBackground()
|
||||
}
|
||||
|
||||
fun setRadius(radius: Int) {
|
||||
this.radius = radius.dpToPx()
|
||||
upBackground()
|
||||
}
|
||||
|
||||
private fun upBackground() {
|
||||
when {
|
||||
isInEditMode -> {
|
||||
background = Selector.shapeBuild()
|
||||
.setCornerRadius(radius)
|
||||
.setStrokeWidth(1.dpToPx())
|
||||
.setDisabledStrokeColor(context.getCompatColor(R.color.md_grey_500))
|
||||
.setDefaultStrokeColor(context.getCompatColor(R.color.secondaryText))
|
||||
//.setSelectedStrokeColor(context.getCompatColor(R.color.accent))
|
||||
.setPressedBgColor(context.getCompatColor(R.color.transparent30))
|
||||
.create()
|
||||
setTextColor(
|
||||
Selector.colorBuild()
|
||||
.setDefaultColor(context.getCompatColor(R.color.secondaryText))
|
||||
//.setSelectedColor(context.getCompatColor(R.color.accent))
|
||||
.setDisabledColor(context.getCompatColor(R.color.md_grey_500))
|
||||
.create()
|
||||
)
|
||||
}
|
||||
isBottomBackground -> {
|
||||
//val isLight = ColorUtils.isColorLight(context.bottomBackground)
|
||||
background = Selector.shapeBuild()
|
||||
.setCornerRadius(radius)
|
||||
.setStrokeWidth(1.dpToPx())
|
||||
.setDisabledStrokeColor(context.getCompatColor(R.color.md_grey_500))
|
||||
// .setDefaultStrokeColor(context.getPrimaryTextColor(isLight))
|
||||
// .setSelectedStrokeColor(context.accentColor)
|
||||
.setPressedBgColor(context.getCompatColor(R.color.transparent30))
|
||||
.create()
|
||||
setTextColor(
|
||||
Selector.colorBuild()
|
||||
// .setDefaultColor(context.getPrimaryTextColor(isLight))
|
||||
// .setSelectedColor(context.accentColor)
|
||||
.setDisabledColor(context.getCompatColor(R.color.md_grey_500))
|
||||
.create()
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
background = Selector.shapeBuild()
|
||||
.setCornerRadius(radius)
|
||||
.setStrokeWidth(1.dpToPx())
|
||||
.setDisabledStrokeColor(context.getCompatColor(R.color.md_grey_500))
|
||||
.setDefaultStrokeColor(ThemeStore.textColorSecondary(context))
|
||||
.setSelectedStrokeColor(ThemeStore.accentColor(context))
|
||||
.setPressedBgColor(context.getCompatColor(R.color.transparent30))
|
||||
.create()
|
||||
setTextColor(
|
||||
Selector.colorBuild()
|
||||
.setDefaultColor(ThemeStore.textColorSecondary(context))
|
||||
.setSelectedColor(ThemeStore.accentColor(context))
|
||||
.setDisabledColor(context.getCompatColor(R.color.md_grey_500))
|
||||
.create()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package io.legato.kazusa.ui.widget.text
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import io.legato.kazusa.lib.theme.Selector
|
||||
import io.legato.kazusa.lib.theme.ThemeStore
|
||||
|
||||
class TextInputLayout(context: Context, attrs: AttributeSet?) : TextInputLayout(context, attrs) {
|
||||
|
||||
init {
|
||||
if (!isInEditMode) {
|
||||
defaultHintTextColor =
|
||||
Selector.colorBuild().setDefaultColor(ThemeStore.accentColor(context)).create()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user