logd加载动画优化

This commit is contained in:
2026-07-10 10:42:28 +08:00
parent 200086301a
commit 1d54d6aa2a
12 changed files with 548 additions and 13 deletions

View File

@@ -0,0 +1,48 @@
kotlin version: 2.2.10
error message: java.nio.file.AccessDeniedException: C:\Users\Administrator\AppData\Local\kotlin\daemon\kotlin-daemon-client-tsmarker11134052769962658558.tmp
error message: Daemon compilation failed: Could not connect to Kotlin compile daemon
java.lang.RuntimeException: Could not connect to Kotlin compile daemon
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:214)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:68)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:64)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:61)
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:102)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:61)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:58)
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:176)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
at org.gradle.internal.Factories$1.create(Factories.java:30)
at org.gradle.internal.work.DefaultWorkerLeaseService.lambda$withLocksAcquired$0(DefaultWorkerLeaseService.java:269)
at org.gradle.internal.work.ResourceLockStatistics$1.measure(ResourceLockStatistics.java:42)
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocksAcquired(DefaultWorkerLeaseService.java:267)
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:259)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)

View File

@@ -0,0 +1,48 @@
kotlin version: 2.2.10
error message: java.nio.file.AccessDeniedException: C:\Users\Administrator\AppData\Local\kotlin\daemon\kotlin-daemon-client-tsmarker4236714666597388016.tmp
error message: Daemon compilation failed: Could not connect to Kotlin compile daemon
java.lang.RuntimeException: Could not connect to Kotlin compile daemon
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:214)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:68)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:64)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:61)
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:102)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:61)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:58)
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:176)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
at org.gradle.internal.Factories$1.create(Factories.java:30)
at org.gradle.internal.work.DefaultWorkerLeaseService.lambda$withLocksAcquired$0(DefaultWorkerLeaseService.java:269)
at org.gradle.internal.work.ResourceLockStatistics$1.measure(ResourceLockStatistics.java:42)
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocksAcquired(DefaultWorkerLeaseService.java:267)
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:259)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)

View File

@@ -0,0 +1,48 @@
kotlin version: 2.2.10
error message: java.nio.file.AccessDeniedException: C:\Users\Administrator\AppData\Local\kotlin\daemon\kotlin-daemon-client-tsmarker12754528945444950713.tmp
error message: Daemon compilation failed: Could not connect to Kotlin compile daemon
java.lang.RuntimeException: Could not connect to Kotlin compile daemon
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:214)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:68)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:64)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:61)
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:102)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:61)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:58)
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:176)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
at org.gradle.internal.Factories$1.create(Factories.java:30)
at org.gradle.internal.work.DefaultWorkerLeaseService.lambda$withLocksAcquired$0(DefaultWorkerLeaseService.java:269)
at org.gradle.internal.work.ResourceLockStatistics$1.measure(ResourceLockStatistics.java:42)
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocksAcquired(DefaultWorkerLeaseService.java:267)
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:259)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)

View File

@@ -39,6 +39,7 @@ import com.ckkj.defense_device.utils.RetrofitNetwork
import com.ckkj.defense_device.utils.UserinfoUtil
import com.ckkj.defense_device.view.DefenseDialogs
import com.ckkj.defense_device.view.LoadingDialog
import com.ckkj.defense_device.view.PullRefreshLayout
import com.ckkj.defense_device.view.showDefenseDialog
import com.gyf.immersionbar.ImmersionBar
import com.maning.mlkitscanner.scan.MNScanManager
@@ -62,6 +63,9 @@ class HomeMenuFragment : Fragment() {
private val pageSize = 10
private var hasMore = true
private var isLoading = false
private var loadingDialog: LoadingDialog? = null
private var lastNoMoreToastTime: Long = 0L
private val noMoreToastInterval: Long = 1500L
private val requestPermissionsLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
@@ -162,7 +166,7 @@ class HomeMenuFragment : Fragment() {
}
private fun fetchInviterInfo(inviteCode: String) {
val loadingDialog = LoadingDialog(requireActivity()).apply { show() }
val loadingDialog = LoadingDialog(requireActivity()).setMessage(this.getString(R.string.loading)).apply { show() }
CoroutineScope(Dispatchers.IO).launch {
try {
val response = RetrofitNetwork.getNetworkService()
@@ -297,9 +301,11 @@ class HomeMenuFragment : Fragment() {
)
}
binding.swipeRefreshLayout.setOnRefreshListener {
binding.swipeRefreshLayout.setOnRefreshListener(object : PullRefreshLayout.OnRefreshListener {
override fun onRefresh() {
refreshDeviceList()
}
})
binding.rvDeviceList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
@@ -307,9 +313,24 @@ class HomeMenuFragment : Fragment() {
val layoutManager = recyclerView.layoutManager as GridLayoutManager
val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition()
val totalItemCount = layoutManager.itemCount
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisibleItemPosition == 0) {
val firstChild = layoutManager.findViewByPosition(0)
firstChild?.let { child ->
val top = child.top
if (top >= 0) {
val offset = (top.toFloat() / dpToPx(80f)).coerceIn(0f, 1f)
binding.gradientLayout.translationY = -offset * dpToPx(20f)
}
}
}
if (!isLoading && hasMore && lastVisibleItemPosition >= totalItemCount - 1 && dy > 0) {
loadMoreDevices()
} else if (!hasMore && !isLoading && lastVisibleItemPosition >= totalItemCount - 1
&& dy > 0 && totalItemCount > 0) {
showNoMoreToast()
}
}
})
@@ -425,7 +446,7 @@ class HomeMenuFragment : Fragment() {
canViewDevice = userInfo?.role == "appadmin" && familyBean.role != "member"
updateAdminUI()
updateFamilyDisplayText(familyName)
fetchDeviceList()
fetchDeviceList(showLoading = true)
FamilySwitchEvent.notifyFamilySwitched(familyName)
} else {
Toast.makeText(requireContext(), response.msg, Toast.LENGTH_SHORT).show()
@@ -439,15 +460,20 @@ class HomeMenuFragment : Fragment() {
}
}
private fun fetchDeviceList(page: Int = 1, isLoadMore: Boolean = false) {
private fun fetchDeviceList(page: Int = 1, isLoadMore: Boolean = false, showLoading: Boolean = false) {
if (!canViewDevice || isLoading) return
isLoading = true
loadingDialog = if (showLoading) LoadingDialog(requireActivity()).setMessage("加载中...").apply { show() } else null
CoroutineScope(Dispatchers.IO).launch {
try {
val response = RetrofitNetwork.getNetworkService().getDeviceList()
withContext(Dispatchers.Main) {
if (!isAdded) return@withContext
loadingDialog?.dismiss()
loadingDialog = null
Log.d("DeviceList", "code = ${response.code}, data = ${response.data}")
if (response.code == 200 && response.data != null) {
val newDevices = response.data
@@ -470,25 +496,36 @@ class HomeMenuFragment : Fragment() {
}
}
isLoading = false
binding.swipeRefreshLayout.finishRefresh()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
if (!isAdded) return@withContext
loadingDialog?.dismiss()
loadingDialog = null
noNetwork()
Toast.makeText(requireContext(), R.string.network_error, Toast.LENGTH_SHORT).show()
isLoading = false
if (isLoadMore) {
hasMore = false
}
binding.swipeRefreshLayout.finishRefresh()
}
}
}
}
private fun showNoMoreToast() {
val now = System.currentTimeMillis()
if (now - lastNoMoreToastTime < noMoreToastInterval) return
lastNoMoreToastTime = now
ToastUtils.showToast(requireContext(), getString(R.string.no_more_data))
}
private fun refreshDeviceList() {
currentPage = 1
hasMore = true
fetchDeviceList(currentPage, false)
binding.swipeRefreshLayout.isRefreshing = false
}
private fun loadMoreDevices() {
@@ -566,6 +603,14 @@ class HomeMenuFragment : Fragment() {
).toInt()
}
private fun dpToPx(dp: Float): Float {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp,
resources.displayMetrics
)
}
private class GridHorizontalSpaceItemDecoration(
private val horizontalSpace: Int
) : RecyclerView.ItemDecoration() {
@@ -588,6 +633,8 @@ class HomeMenuFragment : Fragment() {
override fun onDestroyView() {
super.onDestroyView()
loadingDialog?.dismiss()
loadingDialog = null
binding.rvFamilyList.adapter = null
binding.rvDeviceList.adapter = null
MessageStatusManager.removeListener(unreadCountChangeListener)

View File

@@ -4,6 +4,8 @@ import android.app.Dialog
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.LayoutInflater
import android.view.WindowManager
@@ -13,6 +15,13 @@ class LoadingDialog(context: Context) {
private val dialog: Dialog = Dialog(context)
private val binding: DialogLoadingBinding
private val handler = Handler(Looper.getMainLooper())
private var showTime: Long = 0
private val dismissRunnable = Runnable {
if (dialog.isShowing) {
dialog.dismiss()
}
}
init {
binding = DialogLoadingBinding.inflate(LayoutInflater.from(context))
@@ -35,15 +44,24 @@ class LoadingDialog(context: Context) {
}
fun show() {
handler.removeCallbacks(dismissRunnable)
showTime = System.currentTimeMillis()
if (!dialog.isShowing) {
dialog.show()
}
}
fun dismiss() {
handler.removeCallbacks(dismissRunnable)
val elapsed = System.currentTimeMillis() - showTime
val MIN_SHOW_TIME = 1000L
if (elapsed >= MIN_SHOW_TIME) {
if (dialog.isShowing) {
dialog.dismiss()
}
} else {
handler.postDelayed(dismissRunnable, MIN_SHOW_TIME - elapsed)
}
}
fun isShowing(): Boolean {

View File

@@ -0,0 +1,288 @@
package com.ckkj.defense_device.view
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.ViewGroup
import android.view.animation.LinearInterpolator
import android.widget.ImageView
import android.widget.TextView
import com.ckkj.defense_device.R
/**
* 自定义下拉刷新控件
* 仿网易云/抖音风格:下拉时列表跟随向下移动,顶部出现 Logo + 文字下拉头。
*
* 使用方式:直接包裹 RecyclerView 等可滚动子 View调用 [setOnRefreshListener] 监听刷新。
*/
class PullRefreshLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
/** 触发刷新的下拉阈值Header 的高度) */
private val headerHeight: Int = dp(120)
/** 触发刷新的最大下拉距离Header 高度的 1.5 倍) */
private val maxPullDistance: Int = (headerHeight * 1.5f).toInt()
private var headerView: View
private val ivRefreshIcon: ImageView
private val tvHint: TextView
/** 子内容 View通常是 RecyclerView */
private var contentView: View? = null
private var downY = 0f
private var isDragging = false
/** 当前下拉偏移量(>= 0 */
private var currentOffset = 0f
/** 是否处于刷新状态 */
private var isRefreshing = false
set(value) {
if (field == value) return
field = value
if (value) {
startLoadingAnimation()
} else {
stopLoadingAnimation()
}
}
private var refreshListener: OnRefreshListener? = null
private var resetAnimator: ValueAnimator? = null
init {
clipToPadding = false
// 添加 Header 视图
headerView = LayoutInflater.from(context).inflate(R.layout.layout_refresh_header, this, false)
addView(headerView)
ivRefreshIcon = headerView.findViewById(R.id.ivRefreshIcon)
tvHint = headerView.findViewById(R.id.tvRefreshHint)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
// 测量 Header
val headerHeightSpec = MeasureSpec.makeMeasureSpec(headerHeight, MeasureSpec.EXACTLY)
headerView.measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
headerHeightSpec
)
// 测量内容 View仅第一个非 Header 子 View
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child === headerView) continue
child.measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
)
contentView = child
}
setMeasuredDimension(width, height)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// Header 布局在容器顶部之上(初始隐藏,下拉时滑入)
headerView.layout(
0,
-headerHeight + currentOffset.toInt(),
width,
currentOffset.toInt()
)
// 内容 View 跟随偏移
contentView?.layout(
0,
currentOffset.toInt(),
width,
height + currentOffset.toInt()
)
}
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
if (isRefreshing) return false
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
downY = ev.y
isDragging = false
cancelResetAnimator()
}
MotionEvent.ACTION_MOVE -> {
val dy = ev.y - downY
if (dy > touchSlop && canPullDown()) {
isDragging = true
return true
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
isDragging = false
}
}
return false
}
override fun onTouchEvent(ev: MotionEvent): Boolean {
if (isRefreshing) return false
when (ev.actionMasked) {
MotionEvent.ACTION_MOVE -> {
val dy = ev.y - downY
if (dy > 0) {
if (!isDragging) {
if (dy > touchSlop) {
isDragging = true
} else {
return true
}
}
// 添加阻尼效果:越往下拉越吃力
val damped = computePullOffset(dy)
currentOffset = damped.coerceAtMost(maxPullDistance.toFloat())
updateHeaderState()
requestLayout()
return true
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (currentOffset >= headerHeight * 0.9f) {
// 触发刷新
triggerRefresh()
} else {
// 回弹
resetToIdle()
}
isDragging = false
return true
}
}
return isDragging
}
/**
* 添加阻尼效果,让下拉越深越慢,模拟弹性手感
*/
private fun computePullOffset(rawDy: Float): Float {
// 原始距离的 0.5 倍为基本偏移,超过阈值后逐渐衰减
val resistance = 1.8f
return (rawDy / resistance).coerceAtMost(maxPullDistance.toFloat())
}
/**
* 判断是否可以从当前位置下拉(列表已经滚动到顶部)
*/
private fun canPullDown(): Boolean {
val content = contentView ?: return false
return !content.canScrollVertically(-1)
}
/**
* 根据下拉距离更新 Header 的视觉状态(旋转角度、提示文字)
*/
private fun updateHeaderState() {
val ratio = (currentOffset / headerHeight).coerceIn(0f, 1f)
// 刷新图标根据下拉比例旋转 0~360 度
ivRefreshIcon.rotation = ratio * 360f
// 文字提示
tvHint.text = when {
currentOffset >= headerHeight * 0.9f -> context.getString(R.string.refresh_hint_release)
else -> context.getString(R.string.refresh_hint_pull)
}
}
private fun triggerRefresh() {
// 平滑动画到 header 高度
val animator = ValueAnimator.ofFloat(currentOffset, headerHeight.toFloat())
animator.duration = 200L
animator.interpolator = LinearInterpolator()
animator.addUpdateListener {
currentOffset = it.animatedValue as Float
requestLayout()
}
animator.start()
resetAnimator = animator
isRefreshing = true
tvHint.text = context.getString(R.string.refresh_hint_refreshing)
refreshListener?.onRefresh()
}
/**
* 结束刷新并回弹
*/
fun finishRefresh() {
if (!isRefreshing) return
isRefreshing = false
resetToIdle()
}
private fun resetToIdle() {
cancelResetAnimator()
val animator = ValueAnimator.ofFloat(currentOffset, 0f)
animator.duration = 300L
animator.interpolator = LinearInterpolator()
animator.addUpdateListener {
currentOffset = it.animatedValue as Float
if (!isRefreshing) {
// 刷新结束后,重置图标旋转角度
ivRefreshIcon.rotation = 0f
tvHint.text = context.getString(R.string.refresh_hint_pull)
}
requestLayout()
}
animator.start()
resetAnimator = animator
}
private fun cancelResetAnimator() {
resetAnimator?.cancel()
resetAnimator = null
}
private fun startLoadingAnimation() {
// 持续旋转动画
ivRefreshIcon.animate()
.rotation(ivRefreshIcon.rotation + 360f)
.setDuration(800L)
.setInterpolator(LinearInterpolator())
.withEndAction {
if (isRefreshing) startLoadingAnimation()
}
.start()
}
private fun stopLoadingAnimation() {
ivRefreshIcon.animate().cancel()
ivRefreshIcon.rotation = 0f
tvHint.text = context.getString(R.string.refresh_hint_pull)
}
fun setOnRefreshListener(listener: OnRefreshListener) {
this.refreshListener = listener
}
interface OnRefreshListener {
fun onRefresh()
}
private fun dp(value: Int): Int {
val density = resources.displayMetrics.density
return (value * density).toInt()
}
}

View File

@@ -20,7 +20,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/uploading"
android:textColor="@color/black"
android:textColor="@color/green"
android:textSize="16sp"/>
</LinearLayout>

View File

@@ -134,7 +134,7 @@
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
<com.ckkj.defense_device.view.PullRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_marginLeft="12dp"
@@ -152,11 +152,11 @@
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:overScrollMode="never"
android:paddingBottom="16dp"
android:background="@android:color/transparent"
tools:listitem="@layout/item_device" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</com.ckkj.defense_device.view.PullRefreshLayout>
<LinearLayout
android:id="@+id/layoutNoPermission"

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingTop="24dp"
android:paddingBottom="20dp">
<ImageView
android:id="@+id/ivRefreshIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@mipmap/setting" />
<TextView
android:id="@+id/tvRefreshHint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/refresh_hint_pull"
android:textColor="@color/green"
android:textSize="13sp" />
</LinearLayout>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@@ -512,4 +512,11 @@
<string name="cancel_account_btn">注销</string>
<string name="confirm_cancel_account_user_account">确定注销该用户吗?</string>
<!-- HomeMenuFragment 列表刷新 -->
<string name="no_more_data">没有更多了</string>
<string name="refreshing">正在刷新…</string>
<string name="refresh_hint_pull">下拉刷新</string>
<string name="refresh_hint_release">松开刷新</string>
<string name="refresh_hint_refreshing">正在刷新…</string>
</resources>

View File

@@ -519,4 +519,11 @@
<string name="current_account">Current Account</string>
<string name="cancel_account_btn">Cancel</string>
<string name="confirm_cancel_account_user_account">Confirm Cancel Account User Account</string>
<!-- Home Menu refresh / load more -->
<string name="no_more_data">No more data</string>
<string name="refreshing">Refreshing…</string>
<string name="refresh_hint_pull">Pull down to refresh</string>
<string name="refresh_hint_release">Release to refresh</string>
<string name="refresh_hint_refreshing">Refreshing…</string>
</resources>