This commit is contained in:
HapeLee committed 2025-06-04 13:33:51 +08:00
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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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()
}
}
@@ -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()
}
}
}