@onekeyfe/react-native-native-list 3.0.105
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +342 -0
- package/android/CMakeLists.txt +20 -0
- package/android/build.gradle +105 -0
- package/android/gradle.properties +4 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/cpp-adapter.cpp +6 -0
- package/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +125 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt +165 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +147 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListPackage.kt +25 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +2939 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +1794 -0
- package/common/fonts/Roobert-Bold.ttf +0 -0
- package/common/fonts/Roobert-Medium.ttf +0 -0
- package/common/fonts/Roobert-Regular.ttf +0 -0
- package/common/fonts/Roobert-SemiBold.ttf +0 -0
- package/docs/DESIGN.md +129 -0
- package/ios/HybridNativeList.swift +131 -0
- package/ios/NativeListCell.swift +2858 -0
- package/ios/NativeListDesignAssets.swift +110 -0
- package/ios/NativeListModels.swift +215 -0
- package/ios/RNCNativeListView.swift +1737 -0
- package/ios/Resources/NativeListIcons.xcassets/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_arrow_bottom.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_arrow_bottom.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_arrow_top.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_arrow_top.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_chart_trending_up.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_chart_trending_up.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_checkbox_checked.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_checkbox_checked.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_checkbox_indeterminate.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_checkbox_indeterminate.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_bottom.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_bottom.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_grabber_ver.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_grabber_ver.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_right_small.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_right_small.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_top.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_top.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_dot_hor.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_dot_hor.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_drag.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_drag.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_error_solid.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_error_solid.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_image_square_waves.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_image_square_waves.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_info_circle.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_info_circle.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_minus_circle.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_minus_circle.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_minus_circle_solid.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_minus_circle_solid.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_pencil.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_pencil.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_plus_circle.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_plus_circle.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_plus_small.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_plus_small.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_questionmark_solid.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_questionmark_solid.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_shield_exclamation.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_shield_exclamation.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_speaker_promote.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_speaker_promote.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_star.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_star.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_star_solid.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_star_solid.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_swap_hor.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_swap_hor.imageset/icon.svg +1 -0
- package/lib/module/NativeList.js +183 -0
- package/lib/module/NativeList.nitro.js +4 -0
- package/lib/module/NativeList.types.js +4 -0
- package/lib/module/NativeList.web.js +167 -0
- package/lib/module/index.js +6 -0
- package/lib/module/models.js +2 -0
- package/lib/module/package.json +1 -0
- package/lib/module/scrolling.js +121 -0
- package/lib/module/selection.js +85 -0
- package/lib/module/validation.js +562 -0
- package/lib/module/web/NativeListWebEngine.js +2238 -0
- package/lib/nitrogen/generated/.gitattributes +1 -0
- package/lib/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
- package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +201 -0
- package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +84 -0
- package/lib/nitrogen/generated/android/c++/JNativeListScrollAlignment.hpp +64 -0
- package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +104 -0
- package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.hpp +54 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_std__string.kt +78 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +177 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/NativeListScrollAlignment.kt +25 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/nativelistOnLoad.kt +35 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +96 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListStateUpdater.kt +23 -0
- package/lib/nitrogen/generated/android/nativelist+autolinking.cmake +83 -0
- package/lib/nitrogen/generated/android/nativelist+autolinking.gradle +27 -0
- package/lib/nitrogen/generated/android/nativelistOnLoad.cpp +58 -0
- package/lib/nitrogen/generated/android/nativelistOnLoad.hpp +34 -0
- package/lib/nitrogen/generated/ios/NativeListModule+autolinking.rb +62 -0
- package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +41 -0
- package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +91 -0
- package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Umbrella.hpp +49 -0
- package/lib/nitrogen/generated/ios/NativeListModuleAutolinking.mm +33 -0
- package/lib/nitrogen/generated/ios/NativeListModuleAutolinking.swift +26 -0
- package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.cpp +11 -0
- package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +180 -0
- package/lib/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +180 -0
- package/lib/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +69 -0
- package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +447 -0
- package/lib/nitrogen/generated/ios/swift/NativeListScrollAlignment.swift +48 -0
- package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +43 -0
- package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +87 -0
- package/lib/nitrogen/generated/shared/c++/NativeListScrollAlignment.hpp +84 -0
- package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +46 -0
- package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +103 -0
- package/lib/nitrogen/generated/shared/json/NativeListConfig.json +16 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeList.d.ts +18 -0
- package/lib/typescript/src/NativeList.nitro.d.ts +29 -0
- package/lib/typescript/src/NativeList.types.d.ts +87 -0
- package/lib/typescript/src/NativeList.web.d.ts +19 -0
- package/lib/typescript/src/index.d.ts +7 -0
- package/lib/typescript/src/models.d.ts +456 -0
- package/lib/typescript/src/scrolling.d.ts +32 -0
- package/lib/typescript/src/selection.d.ts +27 -0
- package/lib/typescript/src/validation.d.ts +7 -0
- package/lib/typescript/src/web/NativeListWebEngine.d.ts +203 -0
- package/nitro.json +23 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
- package/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +201 -0
- package/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +84 -0
- package/nitrogen/generated/android/c++/JNativeListScrollAlignment.hpp +64 -0
- package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +104 -0
- package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.hpp +54 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_std__string.kt +78 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +177 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/NativeListScrollAlignment.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/nativelistOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +96 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListStateUpdater.kt +23 -0
- package/nitrogen/generated/android/nativelist+autolinking.cmake +83 -0
- package/nitrogen/generated/android/nativelist+autolinking.gradle +27 -0
- package/nitrogen/generated/android/nativelistOnLoad.cpp +58 -0
- package/nitrogen/generated/android/nativelistOnLoad.hpp +34 -0
- package/nitrogen/generated/ios/NativeListModule+autolinking.rb +62 -0
- package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +41 -0
- package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +91 -0
- package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Umbrella.hpp +49 -0
- package/nitrogen/generated/ios/NativeListModuleAutolinking.mm +33 -0
- package/nitrogen/generated/ios/NativeListModuleAutolinking.swift +26 -0
- package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +180 -0
- package/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +180 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +69 -0
- package/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +447 -0
- package/nitrogen/generated/ios/swift/NativeListScrollAlignment.swift +48 -0
- package/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +43 -0
- package/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +87 -0
- package/nitrogen/generated/shared/c++/NativeListScrollAlignment.hpp +84 -0
- package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +46 -0
- package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +103 -0
- package/nitrogen/generated/shared/json/NativeListConfig.json +16 -0
- package/package.json +126 -0
- package/react-native-native-list.podspec +38 -0
- package/react-native.config.js +19 -0
- package/src/NativeList.nitro.ts +48 -0
- package/src/NativeList.tsx +298 -0
- package/src/NativeList.types.ts +125 -0
- package/src/NativeList.web.tsx +258 -0
- package/src/index.ts +35 -0
- package/src/models.ts +604 -0
- package/src/scrolling.ts +189 -0
- package/src/selection.ts +159 -0
- package/src/validation.ts +877 -0
- package/src/web/NativeListWebEngine.ts +3767 -0
|
@@ -0,0 +1,1794 @@
|
|
|
1
|
+
package com.margelo.nitro.nativelist
|
|
2
|
+
|
|
3
|
+
import android.animation.TimeInterpolator
|
|
4
|
+
import android.graphics.Canvas
|
|
5
|
+
import android.graphics.Color
|
|
6
|
+
import android.graphics.Paint
|
|
7
|
+
import android.graphics.RectF
|
|
8
|
+
import android.graphics.drawable.GradientDrawable
|
|
9
|
+
import android.os.Bundle
|
|
10
|
+
import android.os.Handler
|
|
11
|
+
import android.os.Looper
|
|
12
|
+
import android.view.HapticFeedbackConstants
|
|
13
|
+
import android.view.Choreographer
|
|
14
|
+
import android.view.Gravity
|
|
15
|
+
import android.view.MotionEvent
|
|
16
|
+
import android.view.View
|
|
17
|
+
import android.view.accessibility.AccessibilityEvent
|
|
18
|
+
import android.view.accessibility.AccessibilityNodeInfo
|
|
19
|
+
import android.widget.FrameLayout
|
|
20
|
+
import android.widget.LinearLayout
|
|
21
|
+
import android.widget.SeekBar
|
|
22
|
+
import android.widget.TextView
|
|
23
|
+
import androidx.recyclerview.widget.GridLayoutManager
|
|
24
|
+
import androidx.recyclerview.widget.ItemTouchHelper
|
|
25
|
+
import androidx.recyclerview.widget.LinearLayoutManager
|
|
26
|
+
import androidx.recyclerview.widget.LinearSmoothScroller
|
|
27
|
+
import androidx.recyclerview.widget.RecyclerView
|
|
28
|
+
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
29
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
30
|
+
import org.json.JSONArray
|
|
31
|
+
import org.json.JSONObject
|
|
32
|
+
import java.lang.ref.WeakReference
|
|
33
|
+
import java.util.UUID
|
|
34
|
+
import kotlin.math.ceil
|
|
35
|
+
import kotlin.math.cos
|
|
36
|
+
import kotlin.math.exp
|
|
37
|
+
import kotlin.math.hypot
|
|
38
|
+
import kotlin.math.max
|
|
39
|
+
import kotlin.math.roundToInt
|
|
40
|
+
import kotlin.math.sin
|
|
41
|
+
import kotlin.math.sqrt
|
|
42
|
+
|
|
43
|
+
class NativeListView(
|
|
44
|
+
private val reactContext: ThemedReactContext,
|
|
45
|
+
) : LinearLayout(reactContext) {
|
|
46
|
+
private class ActionAnchorRecord(
|
|
47
|
+
val token: String,
|
|
48
|
+
origin: NativeListActionOrigin,
|
|
49
|
+
) {
|
|
50
|
+
val sourceView = WeakReference(origin.sourceView)
|
|
51
|
+
val ownerRowView = WeakReference(origin.ownerRowView)
|
|
52
|
+
val bindingEpoch = origin.bindingEpoch
|
|
53
|
+
var open = false
|
|
54
|
+
var invalidatedReason: String? = null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private sealed class ScrollRequest {
|
|
58
|
+
data class Key(
|
|
59
|
+
val key: String,
|
|
60
|
+
val animated: Boolean,
|
|
61
|
+
val alignment: String,
|
|
62
|
+
val viewPosition: Double,
|
|
63
|
+
val viewOffset: Double,
|
|
64
|
+
) : ScrollRequest()
|
|
65
|
+
|
|
66
|
+
data class Index(
|
|
67
|
+
val index: Int,
|
|
68
|
+
val animated: Boolean,
|
|
69
|
+
val alignment: String,
|
|
70
|
+
val viewPosition: Double,
|
|
71
|
+
val viewOffset: Double,
|
|
72
|
+
) : ScrollRequest()
|
|
73
|
+
|
|
74
|
+
data class Offset(val offset: Double, val animated: Boolean) : ScrollRequest()
|
|
75
|
+
data class End(val animated: Boolean) : ScrollRequest()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
var onRowAction: ((String) -> Unit)? = null
|
|
79
|
+
var onActionAnchorInvalidated: ((String) -> Unit)? = null
|
|
80
|
+
var onSelectionDelta: ((String) -> Unit)? = null
|
|
81
|
+
var onReorder: ((String) -> Unit)? = null
|
|
82
|
+
var onEndReached: ((String) -> Unit)? = null
|
|
83
|
+
var onVisibleRangeChanged: ((String) -> Unit)? = null
|
|
84
|
+
private val density = resources.displayMetrics.density
|
|
85
|
+
private val recyclerView = RecyclerView(context)
|
|
86
|
+
private val refreshLayout = SwipeRefreshLayout(context)
|
|
87
|
+
private val contentContainer = FrameLayout(context)
|
|
88
|
+
private val adapter = NativeListAdapter(reactContext)
|
|
89
|
+
private val layoutManager = GridLayoutManager(context, 1)
|
|
90
|
+
private val reorderPlaceholderDecoration = ReorderPlaceholderDecoration(
|
|
91
|
+
adapter = adapter,
|
|
92
|
+
insetPx = dp(REORDER_PLACEHOLDER_INSET_DP),
|
|
93
|
+
radiusPx = dp(REORDER_PLACEHOLDER_RADIUS_DP).toFloat(),
|
|
94
|
+
)
|
|
95
|
+
private val reorderSpringInterpolator = ReorderSpringInterpolator(
|
|
96
|
+
damping = REORDER_SPRING_DAMPING,
|
|
97
|
+
stiffness = REORDER_SPRING_STIFFNESS,
|
|
98
|
+
mass = REORDER_SPRING_MASS,
|
|
99
|
+
durationSeconds = REORDER_SPRING_DURATION_MS / 1_000.0,
|
|
100
|
+
)
|
|
101
|
+
private val footerView = NativeListRowView(reactContext)
|
|
102
|
+
private val sectionIndexView = NativeListSectionIndexView(context)
|
|
103
|
+
private val sectionIndexPreview = TextView(context)
|
|
104
|
+
private var config: NativeListConfig? = null
|
|
105
|
+
private var stickyDecoration: StickySectionHeaderDecoration? = null
|
|
106
|
+
private var spacingDecoration: ItemSpacingDecoration? = null
|
|
107
|
+
private var itemTouchHelper: ItemTouchHelper? = null
|
|
108
|
+
private var reorderTouchListener: RecyclerView.OnItemTouchListener? = null
|
|
109
|
+
private var reorderTouchHandler: Handler? = null
|
|
110
|
+
private var endReachedGeneration: Int? = null
|
|
111
|
+
private var lastVisibleRangeSignature: String? = null
|
|
112
|
+
private var visibleEventScheduled = false
|
|
113
|
+
private var dragFrom = RecyclerView.NO_POSITION
|
|
114
|
+
private var dragTo = RecyclerView.NO_POSITION
|
|
115
|
+
private var pendingReorder: List<NativeListItem>? = null
|
|
116
|
+
private var sectionIndexEntries: List<NativeListSectionIndexEntry> = emptyList()
|
|
117
|
+
private var sectionIndexScrubbing = false
|
|
118
|
+
private var sectionIndexProgrammaticScroll = false
|
|
119
|
+
private var sectionIndexHapticsEnabled = true
|
|
120
|
+
private var pendingScrollRequest: ScrollRequest? = null
|
|
121
|
+
private var contentScrollOffsetPx = 0
|
|
122
|
+
private val actionAnchorInstanceId = UUID.randomUUID().toString()
|
|
123
|
+
private var actionAnchorCounter = 0L
|
|
124
|
+
private var actionAnchor: ActionAnchorRecord? = null
|
|
125
|
+
private var lastLayoutWidth = -1
|
|
126
|
+
private var lastLayoutHeight = -1
|
|
127
|
+
private var lastLayoutDirection = layoutDirection
|
|
128
|
+
private var disposed = false
|
|
129
|
+
|
|
130
|
+
init {
|
|
131
|
+
orientation = VERTICAL
|
|
132
|
+
recyclerView.adapter = adapter
|
|
133
|
+
recyclerView.layoutManager = layoutManager
|
|
134
|
+
recyclerView.itemAnimator = null
|
|
135
|
+
recyclerView.addItemDecoration(reorderPlaceholderDecoration)
|
|
136
|
+
recyclerView.setHasFixedSize(false)
|
|
137
|
+
layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
|
|
138
|
+
override fun getSpanSize(position: Int): Int {
|
|
139
|
+
if (config?.layout != "grid") return 1
|
|
140
|
+
val type = adapter.itemAt(position)?.type
|
|
141
|
+
return if (type == "sectionHeader" || type == "system" || type == "action") {
|
|
142
|
+
layoutManager.spanCount
|
|
143
|
+
} else {
|
|
144
|
+
1
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
refreshLayout.addView(
|
|
149
|
+
recyclerView,
|
|
150
|
+
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
|
|
151
|
+
)
|
|
152
|
+
contentContainer.addView(
|
|
153
|
+
refreshLayout,
|
|
154
|
+
FrameLayout.LayoutParams(
|
|
155
|
+
FrameLayout.LayoutParams.MATCH_PARENT,
|
|
156
|
+
FrameLayout.LayoutParams.MATCH_PARENT,
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
contentContainer.addView(
|
|
160
|
+
sectionIndexView,
|
|
161
|
+
FrameLayout.LayoutParams(dp(48), FrameLayout.LayoutParams.MATCH_PARENT, Gravity.END),
|
|
162
|
+
)
|
|
163
|
+
sectionIndexPreview.apply {
|
|
164
|
+
gravity = Gravity.CENTER
|
|
165
|
+
textSize = NativeListScale.font(resources, 28f)
|
|
166
|
+
typeface = NativeListFonts.semibold(context)
|
|
167
|
+
visibility = GONE
|
|
168
|
+
alpha = 0f
|
|
169
|
+
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
|
|
170
|
+
}
|
|
171
|
+
contentContainer.addView(
|
|
172
|
+
sectionIndexPreview,
|
|
173
|
+
FrameLayout.LayoutParams(dp(72), dp(72), Gravity.CENTER),
|
|
174
|
+
)
|
|
175
|
+
addView(contentContainer, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
|
|
176
|
+
footerView.visibility = GONE
|
|
177
|
+
addView(footerView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
|
178
|
+
|
|
179
|
+
adapter.onRowPress = ::handleRowPress
|
|
180
|
+
adapter.onAction = ::handleAction
|
|
181
|
+
adapter.onBindingInvalidated = ::handleBindingInvalidated
|
|
182
|
+
adapter.checkboxState = ::resolveCheckboxState
|
|
183
|
+
footerView.onRowPress = ::handleRowPress
|
|
184
|
+
footerView.onAction = ::handleAction
|
|
185
|
+
footerView.onBindingInvalidated = ::handleBindingInvalidated
|
|
186
|
+
sectionIndexView.onSelect = ::selectSectionIndex
|
|
187
|
+
sectionIndexView.onInteractionEnded = { finishSectionIndexInteraction() }
|
|
188
|
+
sectionIndexView.visibility = GONE
|
|
189
|
+
|
|
190
|
+
refreshLayout.isEnabled = false
|
|
191
|
+
refreshLayout.setOnRefreshListener {
|
|
192
|
+
emit(
|
|
193
|
+
ROW_ACTION,
|
|
194
|
+
JSONObject().put("actionKey", "nativeList.refresh"),
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
|
199
|
+
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
|
200
|
+
when (newState) {
|
|
201
|
+
RecyclerView.SCROLL_STATE_DRAGGING -> {
|
|
202
|
+
sectionIndexProgrammaticScroll = false
|
|
203
|
+
invalidateActionAnchor("scroll")
|
|
204
|
+
}
|
|
205
|
+
RecyclerView.SCROLL_STATE_IDLE -> sectionIndexProgrammaticScroll = false
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
|
210
|
+
if (dx != 0 || dy != 0) invalidateActionAnchor("scroll")
|
|
211
|
+
contentScrollOffsetPx = (
|
|
212
|
+
contentScrollOffsetPx +
|
|
213
|
+
if (layoutManager.orientation == RecyclerView.VERTICAL) dy else dx
|
|
214
|
+
).coerceAtLeast(0)
|
|
215
|
+
syncSectionIndexToVisibleRows()
|
|
216
|
+
scheduleVisibleEvent()
|
|
217
|
+
checkEndReached()
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
223
|
+
super.onLayout(changed, left, top, right, bottom)
|
|
224
|
+
val nextWidth = right - left
|
|
225
|
+
val nextHeight = bottom - top
|
|
226
|
+
if (
|
|
227
|
+
lastLayoutWidth >= 0 &&
|
|
228
|
+
(nextWidth != lastLayoutWidth || nextHeight != lastLayoutHeight || layoutDirection != lastLayoutDirection)
|
|
229
|
+
) {
|
|
230
|
+
invalidateActionAnchor("layout")
|
|
231
|
+
}
|
|
232
|
+
lastLayoutWidth = nextWidth
|
|
233
|
+
lastLayoutHeight = nextHeight
|
|
234
|
+
lastLayoutDirection = layoutDirection
|
|
235
|
+
performPendingScrollIfNeeded()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
fun applySnapshot(snapshotJson: String) {
|
|
239
|
+
val next = try {
|
|
240
|
+
NativeListConfig.parse(snapshotJson)
|
|
241
|
+
} catch (_: Exception) {
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
invalidateActionAnchor("snapshot")
|
|
245
|
+
val previous = config
|
|
246
|
+
if (previous != null && canApplyStableContentUpdate(previous, next)) {
|
|
247
|
+
val changedSummaryKeys = previous.items.indices.mapNotNull { index ->
|
|
248
|
+
previous.items[index].key.takeIf {
|
|
249
|
+
previous.items[index].content != next.items[index].content
|
|
250
|
+
}
|
|
251
|
+
}.toSet()
|
|
252
|
+
config = next
|
|
253
|
+
adapter.theme = next.theme
|
|
254
|
+
adapter.layout = next.layout
|
|
255
|
+
adapter.orientation = next.orientation
|
|
256
|
+
adapter.selectedKeys = next.selectedKeys
|
|
257
|
+
adapter.submitList(next.items) {
|
|
258
|
+
recyclerView.post {
|
|
259
|
+
bindVisibleSelection(changedSummaryKeys)
|
|
260
|
+
bindFooterSelection()
|
|
261
|
+
performPendingScrollIfNeeded()
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
config = next
|
|
267
|
+
endReachedGeneration = null
|
|
268
|
+
pendingReorder = null
|
|
269
|
+
adapter.theme = next.theme
|
|
270
|
+
adapter.layout = next.layout
|
|
271
|
+
adapter.orientation = next.orientation
|
|
272
|
+
adapter.selectedKeys = next.selectedKeys
|
|
273
|
+
configureSectionIndex(next)
|
|
274
|
+
updateLayout(next)
|
|
275
|
+
adapter.submitList(next.items) {
|
|
276
|
+
relayoutContents()
|
|
277
|
+
performPendingScrollIfNeeded()
|
|
278
|
+
syncSectionIndexToVisibleRows()
|
|
279
|
+
scheduleVisibleEvent()
|
|
280
|
+
if (next.items.isNotEmpty()) checkEndReached()
|
|
281
|
+
}
|
|
282
|
+
refreshLayout.isEnabled = next.pullToRefresh
|
|
283
|
+
refreshLayout.isRefreshing = next.refreshing
|
|
284
|
+
bindFooter(next)
|
|
285
|
+
updateReordering(next)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A controlled selection update sends the snapshot back after the native
|
|
290
|
+
* selection delta. Its structure is unchanged; only selectedKeys and the
|
|
291
|
+
* fixed-height summary copy may differ. Keep RecyclerView's current layout
|
|
292
|
+
* and scroll anchor for that echo instead of rebuilding decorations and
|
|
293
|
+
* forcing the host through another layout pass.
|
|
294
|
+
*/
|
|
295
|
+
private fun canApplyStableContentUpdate(
|
|
296
|
+
previous: NativeListConfig,
|
|
297
|
+
next: NativeListConfig,
|
|
298
|
+
): Boolean {
|
|
299
|
+
if (
|
|
300
|
+
previous.generation != next.generation ||
|
|
301
|
+
previous.layout != next.layout ||
|
|
302
|
+
previous.orientation != next.orientation ||
|
|
303
|
+
previous.gridColumns != next.gridColumns ||
|
|
304
|
+
previous.stickyHeaders != next.stickyHeaders ||
|
|
305
|
+
previous.contentPadding != next.contentPadding ||
|
|
306
|
+
previous.contentPaddingHorizontal != next.contentPaddingHorizontal ||
|
|
307
|
+
previous.contentPaddingTop != next.contentPaddingTop ||
|
|
308
|
+
previous.contentPaddingBottom != next.contentPaddingBottom ||
|
|
309
|
+
previous.itemSpacing != next.itemSpacing ||
|
|
310
|
+
previous.selectionMode != next.selectionMode ||
|
|
311
|
+
previous.rowPressToggles != next.rowPressToggles ||
|
|
312
|
+
previous.reorderable != next.reorderable ||
|
|
313
|
+
previous.pullToRefresh != next.pullToRefresh ||
|
|
314
|
+
previous.refreshing != next.refreshing ||
|
|
315
|
+
previous.loadMore != next.loadMore ||
|
|
316
|
+
previous.endReachedThreshold != next.endReachedThreshold ||
|
|
317
|
+
previous.sectionIndexEnabled != next.sectionIndexEnabled ||
|
|
318
|
+
previous.sectionIndexHapticsEnabled != next.sectionIndexHapticsEnabled ||
|
|
319
|
+
previous.theme?.toString() != next.theme?.toString() ||
|
|
320
|
+
previous.fixedFooter?.content != next.fixedFooter?.content ||
|
|
321
|
+
previous.items.size != next.items.size
|
|
322
|
+
) {
|
|
323
|
+
return false
|
|
324
|
+
}
|
|
325
|
+
return previous.items.indices.all { index ->
|
|
326
|
+
val oldItem = previous.items[index]
|
|
327
|
+
val newItem = next.items[index]
|
|
328
|
+
oldItem.key == newItem.key &&
|
|
329
|
+
oldItem.type == newItem.type &&
|
|
330
|
+
(oldItem.content == newItem.content || isStableSummaryUpdate(oldItem, newItem))
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private fun isStableSummaryUpdate(
|
|
335
|
+
previous: NativeListItem,
|
|
336
|
+
next: NativeListItem,
|
|
337
|
+
): Boolean {
|
|
338
|
+
if (
|
|
339
|
+
previous.type != "sectionHeader" ||
|
|
340
|
+
previous.json.optString("variant") != "summary" ||
|
|
341
|
+
next.json.optString("variant") != "summary"
|
|
342
|
+
) {
|
|
343
|
+
return false
|
|
344
|
+
}
|
|
345
|
+
val previousStructure = JSONObject(previous.content).apply {
|
|
346
|
+
remove("title")
|
|
347
|
+
remove("value")
|
|
348
|
+
}
|
|
349
|
+
val nextStructure = JSONObject(next.content).apply {
|
|
350
|
+
remove("title")
|
|
351
|
+
remove("value")
|
|
352
|
+
}
|
|
353
|
+
return previousStructure.toString() == nextStructure.toString()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
fun applyPatches(patchesJson: String) {
|
|
357
|
+
val current = config ?: return
|
|
358
|
+
val patches = try {
|
|
359
|
+
JSONArray(patchesJson)
|
|
360
|
+
} catch (_: Exception) {
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
val indexByKey = current.items.withIndex().associate { it.value.key to it.index }
|
|
364
|
+
val pending = ArrayList<Pair<Int, JSONObject>>(patches.length())
|
|
365
|
+
val seen = HashSet<String>()
|
|
366
|
+
for (index in 0 until patches.length()) {
|
|
367
|
+
val patch = patches.optJSONObject(index) ?: return
|
|
368
|
+
val key = patch.optString("key")
|
|
369
|
+
val itemIndex = indexByKey[key] ?: return
|
|
370
|
+
if (!seen.add(key)) return
|
|
371
|
+
if (patch.optString("type") != current.items[itemIndex].type) return
|
|
372
|
+
val changes = patch.optJSONObject("changes") ?: return
|
|
373
|
+
pending.add(itemIndex to changes)
|
|
374
|
+
}
|
|
375
|
+
val nextItems = current.items.toMutableList()
|
|
376
|
+
val selected = LinkedHashSet(current.selectedKeys)
|
|
377
|
+
try {
|
|
378
|
+
pending.forEach { (index, changes) ->
|
|
379
|
+
val previous = nextItems[index]
|
|
380
|
+
val merged = NativeListItem.parse(mergeRow(previous.json, changes))
|
|
381
|
+
nextItems[index] = merged
|
|
382
|
+
if (changes.has("selected")) {
|
|
383
|
+
if (changes.optBoolean("selected")) selected.add(merged.key) else selected.remove(merged.key)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} catch (_: Exception) {
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
invalidateActionAnchor("snapshot")
|
|
390
|
+
val next = current.copy(items = nextItems, selectedKeys = selected)
|
|
391
|
+
config = next
|
|
392
|
+
adapter.selectedKeys = selected
|
|
393
|
+
adapter.submitList(nextItems) { relayoutContents() }
|
|
394
|
+
bindFooter(next)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
fun reconcileSelection(selectedKeysJson: String) {
|
|
398
|
+
val current = config ?: return
|
|
399
|
+
val array = try {
|
|
400
|
+
JSONArray(selectedKeysJson)
|
|
401
|
+
} catch (_: Exception) {
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
val known = current.items.filter { it.isSelectable }.mapTo(HashSet()) { it.key }
|
|
405
|
+
val next = LinkedHashSet<String>()
|
|
406
|
+
for (index in 0 until array.length()) {
|
|
407
|
+
val key = array.optString(index)
|
|
408
|
+
if (!known.contains(key)) return
|
|
409
|
+
next.add(key)
|
|
410
|
+
}
|
|
411
|
+
if (current.selectionMode == "single" && next.size > 1) return
|
|
412
|
+
config = current.copy(selectedKeys = next)
|
|
413
|
+
adapter.selectedKeys = next
|
|
414
|
+
notifySelectionChanged()
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
fun scrollToKey(
|
|
418
|
+
key: String,
|
|
419
|
+
animated: Boolean,
|
|
420
|
+
alignment: String,
|
|
421
|
+
viewPosition: Double = 0.0,
|
|
422
|
+
viewOffset: Double = 0.0,
|
|
423
|
+
) {
|
|
424
|
+
requestScroll(ScrollRequest.Key(key, animated, alignment, viewPosition, viewOffset))
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
fun scrollToIndex(
|
|
428
|
+
index: Int,
|
|
429
|
+
animated: Boolean,
|
|
430
|
+
alignment: String,
|
|
431
|
+
viewPosition: Double = 0.0,
|
|
432
|
+
viewOffset: Double = 0.0,
|
|
433
|
+
) {
|
|
434
|
+
requestScroll(ScrollRequest.Index(index, animated, alignment, viewPosition, viewOffset))
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
fun scrollToOffset(offset: Double, animated: Boolean) {
|
|
438
|
+
requestScroll(ScrollRequest.Offset(offset, animated))
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
fun scrollToEnd(animated: Boolean) {
|
|
442
|
+
requestScroll(ScrollRequest.End(animated))
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private fun requestScroll(request: ScrollRequest) {
|
|
446
|
+
if (performScroll(request)) {
|
|
447
|
+
pendingScrollRequest = null
|
|
448
|
+
} else {
|
|
449
|
+
pendingScrollRequest = request
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private fun performPendingScrollIfNeeded() {
|
|
454
|
+
val request = pendingScrollRequest ?: return
|
|
455
|
+
if (performScroll(request)) pendingScrollRequest = null
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private fun performScroll(request: ScrollRequest): Boolean {
|
|
459
|
+
val current = config ?: return false
|
|
460
|
+
if (adapter.itemCount != current.items.size) return false
|
|
461
|
+
if (recyclerView.width <= 0 || recyclerView.height <= 0) return false
|
|
462
|
+
return when (request) {
|
|
463
|
+
is ScrollRequest.Key -> {
|
|
464
|
+
val index = current.items.indexOfFirst { it.key == request.key }
|
|
465
|
+
if (index >= 0) {
|
|
466
|
+
performIndexScroll(
|
|
467
|
+
index,
|
|
468
|
+
request.animated,
|
|
469
|
+
request.alignment,
|
|
470
|
+
request.viewPosition,
|
|
471
|
+
request.viewOffset,
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
true
|
|
475
|
+
}
|
|
476
|
+
is ScrollRequest.Index -> {
|
|
477
|
+
if (request.index in current.items.indices) {
|
|
478
|
+
performIndexScroll(
|
|
479
|
+
request.index,
|
|
480
|
+
request.animated,
|
|
481
|
+
request.alignment,
|
|
482
|
+
request.viewPosition,
|
|
483
|
+
request.viewOffset,
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
true
|
|
487
|
+
}
|
|
488
|
+
is ScrollRequest.Offset -> {
|
|
489
|
+
performOffsetScroll(request.offset, request.animated)
|
|
490
|
+
true
|
|
491
|
+
}
|
|
492
|
+
is ScrollRequest.End -> {
|
|
493
|
+
if (current.items.isNotEmpty()) {
|
|
494
|
+
performIndexScroll(
|
|
495
|
+
current.items.lastIndex,
|
|
496
|
+
request.animated,
|
|
497
|
+
"end",
|
|
498
|
+
1.0,
|
|
499
|
+
0.0,
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
true
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private fun performIndexScroll(
|
|
508
|
+
index: Int,
|
|
509
|
+
animated: Boolean,
|
|
510
|
+
alignment: String,
|
|
511
|
+
requestedViewPosition: Double,
|
|
512
|
+
viewOffset: Double,
|
|
513
|
+
) {
|
|
514
|
+
val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
|
|
515
|
+
val visibleView = manager.findViewByPosition(index)
|
|
516
|
+
val viewPosition = if (alignment == "nearest") {
|
|
517
|
+
val viewportStart = if (manager.orientation == RecyclerView.VERTICAL) {
|
|
518
|
+
manager.paddingTop
|
|
519
|
+
} else {
|
|
520
|
+
manager.paddingLeft
|
|
521
|
+
}
|
|
522
|
+
val viewportEnd = if (manager.orientation == RecyclerView.VERTICAL) {
|
|
523
|
+
manager.height - manager.paddingBottom
|
|
524
|
+
} else {
|
|
525
|
+
manager.width - manager.paddingRight
|
|
526
|
+
}
|
|
527
|
+
if (visibleView != null) {
|
|
528
|
+
val itemStart = decoratedStart(manager, visibleView)
|
|
529
|
+
val itemEnd = decoratedEnd(manager, visibleView)
|
|
530
|
+
when {
|
|
531
|
+
itemStart < viewportStart -> 0.0
|
|
532
|
+
itemEnd > viewportEnd -> 1.0
|
|
533
|
+
else -> return
|
|
534
|
+
}
|
|
535
|
+
} else {
|
|
536
|
+
val firstVisible = manager.findFirstVisibleItemPosition()
|
|
537
|
+
if (firstVisible != RecyclerView.NO_POSITION && index < firstVisible) 0.0 else 1.0
|
|
538
|
+
}
|
|
539
|
+
} else {
|
|
540
|
+
requestedViewPosition.coerceIn(0.0, 1.0)
|
|
541
|
+
}
|
|
542
|
+
val offsetPx = (viewOffset * density).roundToInt()
|
|
543
|
+
|
|
544
|
+
if (visibleView != null) {
|
|
545
|
+
alignVisibleView(manager, visibleView, viewPosition, offsetPx, animated)
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
if (animated) {
|
|
549
|
+
val smoothScroller = object : LinearSmoothScroller(context) {
|
|
550
|
+
override fun getVerticalSnapPreference(): Int = SNAP_TO_START
|
|
551
|
+
override fun getHorizontalSnapPreference(): Int = SNAP_TO_START
|
|
552
|
+
|
|
553
|
+
override fun calculateDyToMakeVisible(view: View, snapPreference: Int): Int {
|
|
554
|
+
if (manager.orientation != RecyclerView.VERTICAL) return 0
|
|
555
|
+
return smoothAlignmentDelta(manager, view, viewPosition, offsetPx)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
override fun calculateDxToMakeVisible(view: View, snapPreference: Int): Int {
|
|
559
|
+
if (manager.orientation != RecyclerView.HORIZONTAL) return 0
|
|
560
|
+
return smoothAlignmentDelta(manager, view, viewPosition, offsetPx)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
smoothScroller.targetPosition = index
|
|
564
|
+
manager.startSmoothScroll(smoothScroller)
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
val provisionalOffset = (viewPosition * viewportLength(manager)).roundToInt() + offsetPx
|
|
569
|
+
manager.scrollToPositionWithOffset(index, provisionalOffset)
|
|
570
|
+
relayoutRecyclerView()
|
|
571
|
+
alignAfterLayout(index, viewPosition, offsetPx)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private fun performOffsetScroll(offset: Double, animated: Boolean) {
|
|
575
|
+
val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
|
|
576
|
+
val target = (offset * density).roundToInt().coerceAtLeast(0)
|
|
577
|
+
if (target == 0) {
|
|
578
|
+
if (animated) recyclerView.smoothScrollToPosition(0)
|
|
579
|
+
else manager.scrollToPositionWithOffset(0, 0)
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
val delta = target - contentScrollOffsetPx
|
|
583
|
+
if (manager.orientation == RecyclerView.VERTICAL) {
|
|
584
|
+
if (animated) recyclerView.smoothScrollBy(0, delta) else recyclerView.scrollBy(0, delta)
|
|
585
|
+
} else {
|
|
586
|
+
if (animated) recyclerView.smoothScrollBy(delta, 0) else recyclerView.scrollBy(delta, 0)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private fun alignAfterLayout(
|
|
591
|
+
index: Int,
|
|
592
|
+
viewPosition: Double,
|
|
593
|
+
viewOffset: Int,
|
|
594
|
+
attemptsRemaining: Int = 2,
|
|
595
|
+
) {
|
|
596
|
+
recyclerView.post {
|
|
597
|
+
val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return@post
|
|
598
|
+
val view = manager.findViewByPosition(index)
|
|
599
|
+
if (view == null) {
|
|
600
|
+
if (attemptsRemaining > 0) {
|
|
601
|
+
alignAfterLayout(index, viewPosition, viewOffset, attemptsRemaining - 1)
|
|
602
|
+
}
|
|
603
|
+
return@post
|
|
604
|
+
}
|
|
605
|
+
alignVisibleView(manager, view, viewPosition, viewOffset, false)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private fun alignVisibleView(
|
|
610
|
+
manager: LinearLayoutManager,
|
|
611
|
+
view: View,
|
|
612
|
+
viewPosition: Double,
|
|
613
|
+
viewOffset: Int,
|
|
614
|
+
animated: Boolean,
|
|
615
|
+
) {
|
|
616
|
+
val itemStart = decoratedStart(manager, view)
|
|
617
|
+
val itemLength = decoratedEnd(manager, view) - itemStart
|
|
618
|
+
val viewportStart = if (manager.orientation == RecyclerView.VERTICAL) {
|
|
619
|
+
manager.paddingTop
|
|
620
|
+
} else {
|
|
621
|
+
manager.paddingLeft
|
|
622
|
+
}
|
|
623
|
+
val targetStart = viewportStart +
|
|
624
|
+
(viewPosition * (viewportLength(manager) - itemLength).coerceAtLeast(0)).roundToInt() +
|
|
625
|
+
viewOffset
|
|
626
|
+
val delta = itemStart - targetStart
|
|
627
|
+
if (manager.orientation == RecyclerView.VERTICAL) {
|
|
628
|
+
if (animated) recyclerView.smoothScrollBy(0, delta) else recyclerView.scrollBy(0, delta)
|
|
629
|
+
} else {
|
|
630
|
+
if (animated) recyclerView.smoothScrollBy(delta, 0) else recyclerView.scrollBy(delta, 0)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private fun smoothAlignmentDelta(
|
|
635
|
+
manager: LinearLayoutManager,
|
|
636
|
+
view: View,
|
|
637
|
+
viewPosition: Double,
|
|
638
|
+
viewOffset: Int,
|
|
639
|
+
): Int {
|
|
640
|
+
val itemStart = decoratedStart(manager, view)
|
|
641
|
+
val itemLength = decoratedEnd(manager, view) - itemStart
|
|
642
|
+
val viewportStart = if (manager.orientation == RecyclerView.VERTICAL) {
|
|
643
|
+
manager.paddingTop
|
|
644
|
+
} else {
|
|
645
|
+
manager.paddingLeft
|
|
646
|
+
}
|
|
647
|
+
val targetStart = viewportStart +
|
|
648
|
+
(viewPosition * (viewportLength(manager) - itemLength).coerceAtLeast(0)).roundToInt() +
|
|
649
|
+
viewOffset
|
|
650
|
+
return targetStart - itemStart
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private fun decoratedStart(manager: LinearLayoutManager, view: View): Int =
|
|
654
|
+
if (manager.orientation == RecyclerView.VERTICAL) {
|
|
655
|
+
manager.getDecoratedTop(view)
|
|
656
|
+
} else {
|
|
657
|
+
manager.getDecoratedLeft(view)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private fun decoratedEnd(manager: LinearLayoutManager, view: View): Int =
|
|
661
|
+
if (manager.orientation == RecyclerView.VERTICAL) {
|
|
662
|
+
manager.getDecoratedBottom(view)
|
|
663
|
+
} else {
|
|
664
|
+
manager.getDecoratedRight(view)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private fun viewportLength(manager: LinearLayoutManager): Int =
|
|
668
|
+
if (manager.orientation == RecyclerView.VERTICAL) {
|
|
669
|
+
manager.height - manager.paddingTop - manager.paddingBottom
|
|
670
|
+
} else {
|
|
671
|
+
manager.width - manager.paddingLeft - manager.paddingRight
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
fun setRefreshing(refreshing: Boolean) {
|
|
675
|
+
refreshLayout.isRefreshing = refreshing
|
|
676
|
+
config = config?.copy(refreshing = refreshing)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
fun setActionAnchorState(stateJson: String) {
|
|
680
|
+
val state = try {
|
|
681
|
+
JSONObject(stateJson)
|
|
682
|
+
} catch (_: Exception) {
|
|
683
|
+
return
|
|
684
|
+
}
|
|
685
|
+
val anchor = actionAnchor ?: return
|
|
686
|
+
if (state.optString("token") != anchor.token || !state.has("open")) return
|
|
687
|
+
if (!state.optBoolean("open")) {
|
|
688
|
+
val sourceView = anchor.sourceView.get()
|
|
689
|
+
if (state.optBoolean("restoreFocus") && isActionAnchorValid(anchor) && sourceView != null) {
|
|
690
|
+
sourceView.requestFocus()
|
|
691
|
+
sourceView.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
|
|
692
|
+
}
|
|
693
|
+
actionAnchor = null
|
|
694
|
+
return
|
|
695
|
+
}
|
|
696
|
+
val invalidatedReason = anchor.invalidatedReason
|
|
697
|
+
if (invalidatedReason != null) {
|
|
698
|
+
emitActionAnchorInvalidated(anchor, invalidatedReason)
|
|
699
|
+
} else if (isActionAnchorValid(anchor)) {
|
|
700
|
+
anchor.open = true
|
|
701
|
+
} else {
|
|
702
|
+
emitActionAnchorInvalidated(anchor, "rebind")
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
fun dispose() {
|
|
707
|
+
if (disposed) return
|
|
708
|
+
invalidateActionAnchor("destroy")
|
|
709
|
+
actionAnchor = null
|
|
710
|
+
disposed = true
|
|
711
|
+
pendingScrollRequest = null
|
|
712
|
+
visibleEventScheduled = false
|
|
713
|
+
reorderTouchHandler?.removeCallbacksAndMessages(null)
|
|
714
|
+
reorderTouchHandler = null
|
|
715
|
+
reorderTouchListener?.let(recyclerView::removeOnItemTouchListener)
|
|
716
|
+
reorderTouchListener = null
|
|
717
|
+
itemTouchHelper?.attachToRecyclerView(null)
|
|
718
|
+
itemTouchHelper = null
|
|
719
|
+
footerView.recycle()
|
|
720
|
+
footerView.dispose()
|
|
721
|
+
recyclerView.swapAdapter(null, false)
|
|
722
|
+
adapter.dispose()
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private fun updateLayout(next: NativeListConfig) {
|
|
726
|
+
val orientation = if (next.orientation == "horizontal") RecyclerView.HORIZONTAL else RecyclerView.VERTICAL
|
|
727
|
+
layoutManager.orientation = orientation
|
|
728
|
+
layoutManager.spanCount = if (next.layout == "grid") next.gridColumns else 1
|
|
729
|
+
layoutManager.spanSizeLookup.invalidateSpanIndexCache()
|
|
730
|
+
val defaultPadding = next.contentPadding
|
|
731
|
+
val horizontalPadding = next.contentPaddingHorizontal ?: defaultPadding
|
|
732
|
+
val topPadding = next.contentPaddingTop ?: defaultPadding
|
|
733
|
+
val bottomPadding = next.contentPaddingBottom ?: defaultPadding
|
|
734
|
+
val indexGutter = if (sectionIndexEntries.isEmpty()) 0 else 48
|
|
735
|
+
recyclerView.setPaddingRelative(
|
|
736
|
+
dp(horizontalPadding),
|
|
737
|
+
dp(topPadding),
|
|
738
|
+
dp(horizontalPadding + indexGutter),
|
|
739
|
+
dp(bottomPadding),
|
|
740
|
+
)
|
|
741
|
+
recyclerView.clipToPadding = false
|
|
742
|
+
recyclerView.isVerticalScrollBarEnabled = sectionIndexEntries.isEmpty()
|
|
743
|
+
|
|
744
|
+
spacingDecoration?.let(recyclerView::removeItemDecoration)
|
|
745
|
+
spacingDecoration = ItemSpacingDecoration(dp(next.itemSpacing)).also(recyclerView::addItemDecoration)
|
|
746
|
+
stickyDecoration?.let(recyclerView::removeItemDecoration)
|
|
747
|
+
stickyDecoration = if (next.stickyHeaders && orientation == RecyclerView.VERTICAL) {
|
|
748
|
+
StickySectionHeaderDecoration(adapter, context, next.theme, density).also(recyclerView::addItemDecoration)
|
|
749
|
+
} else null
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private fun configureSectionIndex(next: NativeListConfig) {
|
|
753
|
+
val previousKey = sectionIndexView.activeIndex?.let { sectionIndexEntries.getOrNull(it) }?.key
|
|
754
|
+
finishSectionIndexInteraction(immediately = true)
|
|
755
|
+
val enabled = next.sectionIndexEnabled &&
|
|
756
|
+
next.layout == "sectioned" &&
|
|
757
|
+
next.orientation != "horizontal"
|
|
758
|
+
sectionIndexEntries = if (enabled) {
|
|
759
|
+
next.items.mapIndexedNotNull { position, item ->
|
|
760
|
+
if (item.type != "sectionHeader") return@mapIndexedNotNull null
|
|
761
|
+
val title = item.json.optString("indexTitle")
|
|
762
|
+
if (title.isEmpty()) null else NativeListSectionIndexEntry(item.key, title, position)
|
|
763
|
+
}
|
|
764
|
+
} else {
|
|
765
|
+
emptyList()
|
|
766
|
+
}
|
|
767
|
+
sectionIndexHapticsEnabled = next.sectionIndexHapticsEnabled
|
|
768
|
+
sectionIndexView.configure(
|
|
769
|
+
sectionIndexEntries.map { it.title },
|
|
770
|
+
themeColor(next.theme, "secondaryText", "#646464"),
|
|
771
|
+
themeColor(next.theme, "accent", "#108303"),
|
|
772
|
+
)
|
|
773
|
+
sectionIndexView.visibility = if (sectionIndexEntries.isEmpty()) GONE else VISIBLE
|
|
774
|
+
sectionIndexPreview.setTextColor(themeColor(next.theme, "inverseText", "#FCFCFC"))
|
|
775
|
+
sectionIndexPreview.background = GradientDrawable().apply {
|
|
776
|
+
setColor(themeColor(next.theme, "inverseBackground", "#202020"))
|
|
777
|
+
cornerRadius = dp(16).toFloat()
|
|
778
|
+
}
|
|
779
|
+
sectionIndexView.setActiveIndex(
|
|
780
|
+
previousKey?.let { key -> sectionIndexEntries.indexOfFirst { it.key == key }.takeIf { it >= 0 } },
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
private fun selectSectionIndex(index: Int, interacting: Boolean) {
|
|
785
|
+
val entry = sectionIndexEntries.getOrNull(index) ?: return
|
|
786
|
+
val changed = sectionIndexView.activeIndex != index
|
|
787
|
+
sectionIndexScrubbing = interacting
|
|
788
|
+
sectionIndexProgrammaticScroll = true
|
|
789
|
+
sectionIndexView.setActiveIndex(index)
|
|
790
|
+
recyclerView.stopScroll()
|
|
791
|
+
scrollToIndex(entry.position, animated = false, alignment = "start")
|
|
792
|
+
if (interacting) {
|
|
793
|
+
sectionIndexPreview.animate().cancel()
|
|
794
|
+
sectionIndexPreview.text = entry.title
|
|
795
|
+
sectionIndexPreview.visibility = VISIBLE
|
|
796
|
+
sectionIndexPreview.alpha = 1f
|
|
797
|
+
if (changed && sectionIndexHapticsEnabled) {
|
|
798
|
+
sectionIndexView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private fun finishSectionIndexInteraction(immediately: Boolean = false) {
|
|
804
|
+
sectionIndexScrubbing = false
|
|
805
|
+
if (immediately) sectionIndexProgrammaticScroll = false
|
|
806
|
+
sectionIndexPreview.animate().cancel()
|
|
807
|
+
if (immediately) {
|
|
808
|
+
sectionIndexPreview.alpha = 0f
|
|
809
|
+
sectionIndexPreview.visibility = GONE
|
|
810
|
+
} else {
|
|
811
|
+
sectionIndexPreview.animate()
|
|
812
|
+
.alpha(0f)
|
|
813
|
+
.setDuration(150)
|
|
814
|
+
.withEndAction { sectionIndexPreview.visibility = GONE }
|
|
815
|
+
.start()
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
private fun syncSectionIndexToVisibleRows() {
|
|
820
|
+
if (sectionIndexScrubbing || sectionIndexProgrammaticScroll || sectionIndexEntries.isEmpty()) return
|
|
821
|
+
val firstVisible = layoutManager.findFirstVisibleItemPosition()
|
|
822
|
+
if (firstVisible == RecyclerView.NO_POSITION) return
|
|
823
|
+
sectionIndexView.setActiveIndex(
|
|
824
|
+
sectionIndexEntries.indexOfLast { it.position <= firstVisible }.takeIf { it >= 0 },
|
|
825
|
+
)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private fun themeColor(theme: JSONObject?, key: String, fallback: String): Int = try {
|
|
829
|
+
parseNativeListColor(theme?.optString(key)?.takeIf(String::isNotEmpty) ?: fallback)
|
|
830
|
+
} catch (_: IllegalArgumentException) {
|
|
831
|
+
parseNativeListColor(fallback)
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
private fun bindFooter(next: NativeListConfig) {
|
|
835
|
+
val footer = next.fixedFooter
|
|
836
|
+
if (footer == null) {
|
|
837
|
+
footerView.visibility = GONE
|
|
838
|
+
footerView.recycle()
|
|
839
|
+
} else {
|
|
840
|
+
footerView.visibility = VISIBLE
|
|
841
|
+
footerView.bind(
|
|
842
|
+
footer,
|
|
843
|
+
next.theme,
|
|
844
|
+
next.layout,
|
|
845
|
+
"vertical",
|
|
846
|
+
null,
|
|
847
|
+
next.selectedKeys.contains(footer.key),
|
|
848
|
+
::resolveCheckboxState,
|
|
849
|
+
)
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
private fun handleRowPress(item: NativeListItem, origin: NativeListActionOrigin) {
|
|
854
|
+
val current = config ?: return
|
|
855
|
+
if (current.rowPressToggles && item.isSelectable && current.selectionMode != "none") {
|
|
856
|
+
updateSelection(NativeSelectionTarget("row", item.key), item.key)
|
|
857
|
+
} else {
|
|
858
|
+
val actionKey = when {
|
|
859
|
+
item.type == "action" -> item.json.optString("actionKey")
|
|
860
|
+
item.type == "system" && item.json.optString("variant") == "retry" -> item.json.optString("actionKey")
|
|
861
|
+
else -> "press"
|
|
862
|
+
}
|
|
863
|
+
val payload = JSONObject()
|
|
864
|
+
.put("rowKey", item.key)
|
|
865
|
+
.put("actionKey", actionKey)
|
|
866
|
+
item.sectionKey?.let { payload.put("sectionKey", it) }
|
|
867
|
+
createActionAnchor(origin)?.let { payload.put("anchor", it) }
|
|
868
|
+
emit(ROW_ACTION, payload)
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private fun handleAction(
|
|
873
|
+
item: NativeListItem,
|
|
874
|
+
actionKey: String,
|
|
875
|
+
target: NativeSelectionTarget?,
|
|
876
|
+
origin: NativeListActionOrigin?,
|
|
877
|
+
) {
|
|
878
|
+
if (item.json.optBoolean("disabled", false)) return
|
|
879
|
+
if (target != null && config?.selectionMode != "none") {
|
|
880
|
+
updateSelection(target, item.key)
|
|
881
|
+
return
|
|
882
|
+
}
|
|
883
|
+
val payload = JSONObject()
|
|
884
|
+
.put("rowKey", item.key)
|
|
885
|
+
.put("actionKey", actionKey)
|
|
886
|
+
item.sectionKey?.let { payload.put("sectionKey", it) }
|
|
887
|
+
origin?.let(::createActionAnchor)?.let { payload.put("anchor", it) }
|
|
888
|
+
emit(ROW_ACTION, payload)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
private fun createActionAnchor(origin: NativeListActionOrigin): JSONObject? {
|
|
892
|
+
if (!isOriginValid(origin)) return null
|
|
893
|
+
invalidateActionAnchor("rebind")
|
|
894
|
+
actionAnchorCounter += 1
|
|
895
|
+
val generation = config?.generation ?: 0
|
|
896
|
+
val token = "$actionAnchorInstanceId:$generation:$actionAnchorCounter:${origin.bindingEpoch}"
|
|
897
|
+
val location = IntArray(2)
|
|
898
|
+
origin.sourceView.getLocationInWindow(location)
|
|
899
|
+
val record = ActionAnchorRecord(token, origin)
|
|
900
|
+
actionAnchor = record
|
|
901
|
+
return JSONObject()
|
|
902
|
+
.put("token", token)
|
|
903
|
+
.put(
|
|
904
|
+
"windowRect",
|
|
905
|
+
JSONObject()
|
|
906
|
+
.put("x", location[0] / density)
|
|
907
|
+
.put("y", location[1] / density)
|
|
908
|
+
.put("width", origin.sourceView.width / density)
|
|
909
|
+
.put("height", origin.sourceView.height / density),
|
|
910
|
+
)
|
|
911
|
+
.put("source", origin.source)
|
|
912
|
+
.put("generation", generation)
|
|
913
|
+
.put("layoutDirection", if (origin.sourceView.layoutDirection == LAYOUT_DIRECTION_RTL) "rtl" else "ltr")
|
|
914
|
+
.also { anchor -> origin.slot?.let { anchor.put("slot", it) } }
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
private fun isOriginValid(origin: NativeListActionOrigin): Boolean =
|
|
918
|
+
origin.ownerRowView.bindingEpoch == origin.bindingEpoch &&
|
|
919
|
+
origin.sourceView.isAttachedToWindow &&
|
|
920
|
+
isDescendantOf(origin.sourceView, origin.ownerRowView)
|
|
921
|
+
|
|
922
|
+
private fun isActionAnchorValid(anchor: ActionAnchorRecord): Boolean {
|
|
923
|
+
if (anchor.invalidatedReason != null) return false
|
|
924
|
+
val sourceView = anchor.sourceView.get() ?: return false
|
|
925
|
+
val ownerRowView = anchor.ownerRowView.get() ?: return false
|
|
926
|
+
return ownerRowView.bindingEpoch == anchor.bindingEpoch &&
|
|
927
|
+
sourceView.isAttachedToWindow &&
|
|
928
|
+
isDescendantOf(sourceView, ownerRowView)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private fun isDescendantOf(view: View, ancestor: View): Boolean {
|
|
932
|
+
var current: Any? = view
|
|
933
|
+
while (current is View) {
|
|
934
|
+
if (current === ancestor) return true
|
|
935
|
+
current = current.parent
|
|
936
|
+
}
|
|
937
|
+
return false
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
private fun handleBindingInvalidated(row: NativeListRowView, epoch: Long) {
|
|
941
|
+
val anchor = actionAnchor ?: return
|
|
942
|
+
if (anchor.ownerRowView.get() === row && anchor.bindingEpoch == epoch) {
|
|
943
|
+
invalidateActionAnchor("rebind")
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
private fun invalidateActionAnchor(reason: String) {
|
|
948
|
+
val anchor = actionAnchor ?: return
|
|
949
|
+
if (anchor.invalidatedReason != null) return
|
|
950
|
+
anchor.invalidatedReason = reason
|
|
951
|
+
if (anchor.open) emitActionAnchorInvalidated(anchor, reason)
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
private fun emitActionAnchorInvalidated(anchor: ActionAnchorRecord, reason: String) {
|
|
955
|
+
if (actionAnchor !== anchor) return
|
|
956
|
+
actionAnchor = null
|
|
957
|
+
emit(
|
|
958
|
+
ACTION_ANCHOR_INVALIDATED,
|
|
959
|
+
JSONObject().put("token", anchor.token).put("reason", reason),
|
|
960
|
+
)
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private fun updateSelection(target: NativeSelectionTarget, sourceKey: String) {
|
|
964
|
+
val current = config ?: return
|
|
965
|
+
if (current.selectionMode == "none") return
|
|
966
|
+
val targets = selectionKeys(target, current.items)
|
|
967
|
+
if (targets.isEmpty()) return
|
|
968
|
+
val before = LinkedHashSet(current.selectedKeys)
|
|
969
|
+
val after = LinkedHashSet(before)
|
|
970
|
+
if (current.selectionMode == "single") {
|
|
971
|
+
val key = targets.first()
|
|
972
|
+
after.clear()
|
|
973
|
+
if (!before.contains(key)) after.add(key)
|
|
974
|
+
} else {
|
|
975
|
+
val allSelected = targets.all(before::contains)
|
|
976
|
+
targets.forEach { if (allSelected) after.remove(it) else after.add(it) }
|
|
977
|
+
}
|
|
978
|
+
if (before == after) return
|
|
979
|
+
config = current.copy(selectedKeys = after)
|
|
980
|
+
adapter.selectedKeys = after
|
|
981
|
+
notifySelectionChanged()
|
|
982
|
+
|
|
983
|
+
val added = after.filterNot(before::contains)
|
|
984
|
+
val removed = before.filterNot(after::contains)
|
|
985
|
+
val payload = JSONObject()
|
|
986
|
+
.put("addedKeys", JSONArray(added))
|
|
987
|
+
.put("removedKeys", JSONArray(removed))
|
|
988
|
+
.put("source", target.scope)
|
|
989
|
+
.put("sourceKey", target.key ?: sourceKey)
|
|
990
|
+
emit(SELECTION_DELTA, payload)
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
private fun selectionKeys(
|
|
994
|
+
target: NativeSelectionTarget,
|
|
995
|
+
items: List<NativeListItem>,
|
|
996
|
+
): List<String> = when (target.scope) {
|
|
997
|
+
"row" -> items.filter { it.key == target.key && it.isSelectable }.map { it.key }
|
|
998
|
+
"section" -> items.filter { it.sectionKey == target.key && it.isSelectable }.map { it.key }
|
|
999
|
+
"list" -> items.filter { it.isSelectable }.map { it.key }
|
|
1000
|
+
else -> emptyList()
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
private fun resolveCheckboxState(
|
|
1004
|
+
item: NativeListItem,
|
|
1005
|
+
target: NativeSelectionTarget?,
|
|
1006
|
+
fallback: String,
|
|
1007
|
+
): String {
|
|
1008
|
+
val current = config ?: return fallback
|
|
1009
|
+
val resolvedTarget = target ?: NativeSelectionTarget("row", item.key)
|
|
1010
|
+
val keys = selectionKeys(resolvedTarget, current.items)
|
|
1011
|
+
if (keys.isEmpty()) return fallback
|
|
1012
|
+
val count = keys.count(current.selectedKeys::contains)
|
|
1013
|
+
return when {
|
|
1014
|
+
count == 0 -> "unchecked"
|
|
1015
|
+
count == keys.size -> "checked"
|
|
1016
|
+
else -> "indeterminate"
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private fun notifySelectionChanged() {
|
|
1021
|
+
adapter.notifyItemRangeChanged(0, adapter.itemCount, SELECTION_PAYLOAD)
|
|
1022
|
+
recyclerView.post { bindVisibleSelection() }
|
|
1023
|
+
bindFooterSelection()
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
private fun bindVisibleSelection(changedSummaryKeys: Set<String> = emptySet()) {
|
|
1027
|
+
val current = config ?: return
|
|
1028
|
+
for (index in 0 until recyclerView.childCount) {
|
|
1029
|
+
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(index)) as? NativeListViewHolder
|
|
1030
|
+
?: continue
|
|
1031
|
+
val boundKey = (holder.rowView.tag as? NativeListItem)?.key ?: continue
|
|
1032
|
+
val position = adapter.positionOfKey(boundKey)
|
|
1033
|
+
val item = adapter.itemAt(position) ?: continue
|
|
1034
|
+
if (item.key in changedSummaryKeys) holder.rowView.bindStableSummary(item)
|
|
1035
|
+
holder.rowView.bindSelection(
|
|
1036
|
+
item,
|
|
1037
|
+
current.theme,
|
|
1038
|
+
current.layout,
|
|
1039
|
+
position,
|
|
1040
|
+
current.selectedKeys.contains(item.key),
|
|
1041
|
+
::resolveCheckboxState,
|
|
1042
|
+
)
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
private fun bindFooterSelection() {
|
|
1047
|
+
config?.let { current ->
|
|
1048
|
+
current.fixedFooter?.let { footer ->
|
|
1049
|
+
footerView.bindSelection(
|
|
1050
|
+
footer,
|
|
1051
|
+
current.theme,
|
|
1052
|
+
current.layout,
|
|
1053
|
+
null,
|
|
1054
|
+
current.selectedKeys.contains(footer.key),
|
|
1055
|
+
::resolveCheckboxState,
|
|
1056
|
+
)
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
private fun updateReordering(next: NativeListConfig) {
|
|
1062
|
+
reorderTouchHandler?.removeCallbacksAndMessages(null)
|
|
1063
|
+
reorderTouchHandler = null
|
|
1064
|
+
reorderTouchListener?.let(recyclerView::removeOnItemTouchListener)
|
|
1065
|
+
reorderTouchListener = null
|
|
1066
|
+
itemTouchHelper?.attachToRecyclerView(null)
|
|
1067
|
+
itemTouchHelper = null
|
|
1068
|
+
adapter.cancelReorder()
|
|
1069
|
+
if (!next.reorderable) return
|
|
1070
|
+
val callback = object : ItemTouchHelper.SimpleCallback(
|
|
1071
|
+
ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT,
|
|
1072
|
+
0,
|
|
1073
|
+
) {
|
|
1074
|
+
private var compactWalletGroupDrag = false
|
|
1075
|
+
private var compactWalletGroupTop = Float.NaN
|
|
1076
|
+
|
|
1077
|
+
override fun isLongPressDragEnabled(): Boolean = false
|
|
1078
|
+
|
|
1079
|
+
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
|
|
1080
|
+
super.onSelectedChanged(viewHolder, actionState)
|
|
1081
|
+
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
|
1082
|
+
val position = viewHolder?.bindingAdapterPosition ?: RecyclerView.NO_POSITION
|
|
1083
|
+
val item = adapter.itemAt(position)
|
|
1084
|
+
reorderPlaceholderDecoration.position = position
|
|
1085
|
+
reorderPlaceholderDecoration.color = reorderActiveBackground(next.theme)
|
|
1086
|
+
compactWalletGroupDrag = item?.type == "walletGroup" && viewHolder != null
|
|
1087
|
+
recyclerView.invalidate()
|
|
1088
|
+
(viewHolder as? NativeListViewHolder)?.rowView?.setReorderActive(true)
|
|
1089
|
+
if (compactWalletGroupDrag && viewHolder != null) {
|
|
1090
|
+
recyclerView.postOnAnimation {
|
|
1091
|
+
if (viewHolder.itemView.isAttachedToWindow) {
|
|
1092
|
+
relayoutRecyclerViewImmediately()
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
|
|
1100
|
+
val item = adapter.itemAt(viewHolder.bindingAdapterPosition)
|
|
1101
|
+
if (item == null || !item.isReorderable) return makeMovementFlags(0, 0)
|
|
1102
|
+
val dragFlags = if (next.orientation == "horizontal") ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT else ItemTouchHelper.UP or ItemTouchHelper.DOWN
|
|
1103
|
+
return makeMovementFlags(dragFlags, 0)
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
override fun onMove(
|
|
1107
|
+
recyclerView: RecyclerView,
|
|
1108
|
+
source: RecyclerView.ViewHolder,
|
|
1109
|
+
target: RecyclerView.ViewHolder,
|
|
1110
|
+
): Boolean {
|
|
1111
|
+
val from = source.bindingAdapterPosition
|
|
1112
|
+
val to = target.bindingAdapterPosition
|
|
1113
|
+
val base = pendingReorder ?: adapter.currentList
|
|
1114
|
+
val fromItem = base.getOrNull(from) ?: return false
|
|
1115
|
+
val toItem = base.getOrNull(to) ?: return false
|
|
1116
|
+
if (!fromItem.isReorderable || !toItem.isReorderable || fromItem.sectionKey != toItem.sectionKey) return false
|
|
1117
|
+
val crossedPosition = dragTo != to
|
|
1118
|
+
if (dragFrom == RecyclerView.NO_POSITION) dragFrom = from
|
|
1119
|
+
dragTo = to
|
|
1120
|
+
if (crossedPosition) {
|
|
1121
|
+
recyclerView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
|
|
1122
|
+
}
|
|
1123
|
+
val displacedView = target.itemView
|
|
1124
|
+
prepareDisplacedReorderAnimation(displacedView)
|
|
1125
|
+
val reordered = adapter.moveReordered(from, to) ?: return false
|
|
1126
|
+
pendingReorder = reordered
|
|
1127
|
+
reorderPlaceholderDecoration.position = to
|
|
1128
|
+
recyclerView.invalidate()
|
|
1129
|
+
recyclerView.postOnAnimation(::relayoutRecyclerViewImmediately)
|
|
1130
|
+
return true
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
|
|
1134
|
+
|
|
1135
|
+
override fun onChildDraw(
|
|
1136
|
+
canvas: Canvas,
|
|
1137
|
+
recyclerView: RecyclerView,
|
|
1138
|
+
viewHolder: RecyclerView.ViewHolder,
|
|
1139
|
+
dX: Float,
|
|
1140
|
+
dY: Float,
|
|
1141
|
+
actionState: Int,
|
|
1142
|
+
isCurrentlyActive: Boolean,
|
|
1143
|
+
) {
|
|
1144
|
+
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
|
1145
|
+
if (compactWalletGroupDrag) {
|
|
1146
|
+
compactWalletGroupTop = viewHolder.itemView.top + dY
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
override fun interpolateOutOfBoundsScroll(
|
|
1153
|
+
recyclerView: RecyclerView,
|
|
1154
|
+
viewSize: Int,
|
|
1155
|
+
viewSizeOutOfBounds: Int,
|
|
1156
|
+
totalSize: Int,
|
|
1157
|
+
msSinceStartScroll: Long,
|
|
1158
|
+
): Int {
|
|
1159
|
+
if (!compactWalletGroupDrag) {
|
|
1160
|
+
return super.interpolateOutOfBoundsScroll(
|
|
1161
|
+
recyclerView,
|
|
1162
|
+
viewSize,
|
|
1163
|
+
viewSizeOutOfBounds,
|
|
1164
|
+
totalSize,
|
|
1165
|
+
msSinceStartScroll,
|
|
1166
|
+
)
|
|
1167
|
+
}
|
|
1168
|
+
if (compactWalletGroupTop.isNaN()) return 0
|
|
1169
|
+
val compactHeight = dp(68)
|
|
1170
|
+
val compactTop = compactWalletGroupTop.roundToInt()
|
|
1171
|
+
val compactOutOfBounds = when {
|
|
1172
|
+
compactTop < recyclerView.paddingTop -> compactTop - recyclerView.paddingTop
|
|
1173
|
+
compactTop + compactHeight > recyclerView.height - recyclerView.paddingBottom ->
|
|
1174
|
+
compactTop + compactHeight - (recyclerView.height - recyclerView.paddingBottom)
|
|
1175
|
+
else -> 0
|
|
1176
|
+
}
|
|
1177
|
+
if (compactOutOfBounds == 0) return 0
|
|
1178
|
+
return super.interpolateOutOfBoundsScroll(
|
|
1179
|
+
recyclerView,
|
|
1180
|
+
compactHeight,
|
|
1181
|
+
compactOutOfBounds,
|
|
1182
|
+
totalSize,
|
|
1183
|
+
msSinceStartScroll,
|
|
1184
|
+
)
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
|
1188
|
+
val rowView = (viewHolder as? NativeListViewHolder)?.rowView
|
|
1189
|
+
val draggedGroupKey = (rowView?.tag as? NativeListItem)?.key
|
|
1190
|
+
super.clearView(recyclerView, viewHolder)
|
|
1191
|
+
val wasCompactWalletGroupDrag = compactWalletGroupDrag
|
|
1192
|
+
if (!wasCompactWalletGroupDrag) rowView?.setReorderActive(false)
|
|
1193
|
+
reorderPlaceholderDecoration.position = RecyclerView.NO_POSITION
|
|
1194
|
+
compactWalletGroupDrag = false
|
|
1195
|
+
compactWalletGroupTop = Float.NaN
|
|
1196
|
+
recyclerView.invalidate()
|
|
1197
|
+
val from = dragFrom
|
|
1198
|
+
val to = dragTo
|
|
1199
|
+
val reordered = pendingReorder
|
|
1200
|
+
val destinationPosition = if (to != RecyclerView.NO_POSITION) {
|
|
1201
|
+
to
|
|
1202
|
+
} else {
|
|
1203
|
+
viewHolder.bindingAdapterPosition
|
|
1204
|
+
}
|
|
1205
|
+
val finishCompactWalletGroup = {
|
|
1206
|
+
if (wasCompactWalletGroupDrag) {
|
|
1207
|
+
recyclerView.postOnAnimation {
|
|
1208
|
+
relayoutRecyclerViewImmediately()
|
|
1209
|
+
val resolvedPosition = draggedGroupKey
|
|
1210
|
+
?.let(adapter::positionOfKey)
|
|
1211
|
+
?.takeIf { it != RecyclerView.NO_POSITION }
|
|
1212
|
+
?: destinationPosition
|
|
1213
|
+
val destinationRow = recyclerView
|
|
1214
|
+
.findViewHolderForAdapterPosition(resolvedPosition)
|
|
1215
|
+
?.let { it as? NativeListViewHolder }
|
|
1216
|
+
?.rowView
|
|
1217
|
+
val groupRow = destinationRow?.takeIf {
|
|
1218
|
+
(it.tag as? NativeListItem)?.type == "walletGroup"
|
|
1219
|
+
} ?: rowView
|
|
1220
|
+
groupRow?.finishWalletGroupReorder(
|
|
1221
|
+
REORDER_SPRING_DURATION_MS,
|
|
1222
|
+
reorderSpringInterpolator,
|
|
1223
|
+
) {
|
|
1224
|
+
recyclerView.postOnAnimation(::relayoutRecyclerViewImmediately)
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
if (from != RecyclerView.NO_POSITION && to != RecyclerView.NO_POSITION && from != to && reordered != null) {
|
|
1230
|
+
val moved = reordered.getOrNull(to)
|
|
1231
|
+
if (moved != null) {
|
|
1232
|
+
config = config?.copy(items = reordered)
|
|
1233
|
+
val payload = JSONObject()
|
|
1234
|
+
.put("key", moved.key)
|
|
1235
|
+
.put("fromIndex", from)
|
|
1236
|
+
.put("toIndex", to)
|
|
1237
|
+
reordered.getOrNull(to - 1)?.let { payload.put("beforeKey", it.key) }
|
|
1238
|
+
reordered.getOrNull(to + 1)?.let { payload.put("afterKey", it.key) }
|
|
1239
|
+
adapter.commitReordered(reordered) {
|
|
1240
|
+
finishCompactWalletGroup()
|
|
1241
|
+
if (!wasCompactWalletGroupDrag) {
|
|
1242
|
+
recyclerView.postOnAnimation(::relayoutRecyclerViewImmediately)
|
|
1243
|
+
}
|
|
1244
|
+
emit(REORDER, payload)
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
} else {
|
|
1248
|
+
adapter.cancelReorder()
|
|
1249
|
+
finishCompactWalletGroup()
|
|
1250
|
+
}
|
|
1251
|
+
dragFrom = RecyclerView.NO_POSITION
|
|
1252
|
+
dragTo = RecyclerView.NO_POSITION
|
|
1253
|
+
pendingReorder = null
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
itemTouchHelper = ItemTouchHelper(callback).also { it.attachToRecyclerView(recyclerView) }
|
|
1257
|
+
val handler = Handler(Looper.getMainLooper())
|
|
1258
|
+
reorderTouchHandler = handler
|
|
1259
|
+
val movementLimit = dp(REORDER_ALLOWABLE_MOVEMENT_DP).toFloat()
|
|
1260
|
+
reorderTouchListener = object : RecyclerView.SimpleOnItemTouchListener() {
|
|
1261
|
+
private var candidate: RecyclerView.ViewHolder? = null
|
|
1262
|
+
private var downX = 0f
|
|
1263
|
+
private var downY = 0f
|
|
1264
|
+
private var dragStarted = false
|
|
1265
|
+
private val startDrag = Runnable {
|
|
1266
|
+
val holder = candidate ?: return@Runnable
|
|
1267
|
+
val position = holder.bindingAdapterPosition
|
|
1268
|
+
val item = adapter.itemAt(position) ?: return@Runnable
|
|
1269
|
+
if (!item.isReorderable) return@Runnable
|
|
1270
|
+
if (item.type != "walletGroup" && recyclerView.isLayoutRequested) {
|
|
1271
|
+
relayoutRecyclerViewImmediately()
|
|
1272
|
+
}
|
|
1273
|
+
dragStarted = true
|
|
1274
|
+
beginDrag(holder)
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
private fun beginDrag(holder: RecyclerView.ViewHolder) {
|
|
1278
|
+
if (candidate !== holder || !holder.itemView.isAttachedToWindow) return
|
|
1279
|
+
itemTouchHelper?.startDrag(holder)
|
|
1280
|
+
holder.itemView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
private fun cancelPending() {
|
|
1284
|
+
handler.removeCallbacks(startDrag)
|
|
1285
|
+
candidate = null
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
override fun onInterceptTouchEvent(recyclerView: RecyclerView, event: MotionEvent): Boolean {
|
|
1289
|
+
when (event.actionMasked) {
|
|
1290
|
+
MotionEvent.ACTION_DOWN -> {
|
|
1291
|
+
cancelPending()
|
|
1292
|
+
dragStarted = false
|
|
1293
|
+
downX = event.x
|
|
1294
|
+
downY = event.y
|
|
1295
|
+
val child = recyclerView.findChildViewUnder(event.x, event.y)
|
|
1296
|
+
candidate = child
|
|
1297
|
+
?.let(recyclerView::getChildViewHolder)
|
|
1298
|
+
?.takeIf { holder ->
|
|
1299
|
+
adapter.itemAt(holder.bindingAdapterPosition)?.let { item ->
|
|
1300
|
+
val holderLocation = IntArray(2)
|
|
1301
|
+
holder.itemView.getLocationOnScreen(holderLocation)
|
|
1302
|
+
item.isReorderable && (
|
|
1303
|
+
item.type != "walletGroup" ||
|
|
1304
|
+
event.rawY in holderLocation[1].toFloat()..(holderLocation[1] + dp(68)).toFloat()
|
|
1305
|
+
)
|
|
1306
|
+
} == true
|
|
1307
|
+
}
|
|
1308
|
+
if (candidate != null) handler.postDelayed(startDrag, REORDER_LONG_PRESS_MS)
|
|
1309
|
+
}
|
|
1310
|
+
MotionEvent.ACTION_MOVE -> if (
|
|
1311
|
+
!dragStarted && hypot(event.x - downX, event.y - downY) > movementLimit
|
|
1312
|
+
) {
|
|
1313
|
+
cancelPending()
|
|
1314
|
+
}
|
|
1315
|
+
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> cancelPending()
|
|
1316
|
+
}
|
|
1317
|
+
return false
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
}.also(recyclerView::addOnItemTouchListener)
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
private fun prepareDisplacedReorderAnimation(view: View) {
|
|
1324
|
+
val previousX = view.x
|
|
1325
|
+
val previousY = view.y
|
|
1326
|
+
view.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
|
|
1327
|
+
override fun onLayoutChange(
|
|
1328
|
+
changedView: View,
|
|
1329
|
+
left: Int,
|
|
1330
|
+
top: Int,
|
|
1331
|
+
right: Int,
|
|
1332
|
+
bottom: Int,
|
|
1333
|
+
oldLeft: Int,
|
|
1334
|
+
oldTop: Int,
|
|
1335
|
+
oldRight: Int,
|
|
1336
|
+
oldBottom: Int,
|
|
1337
|
+
) {
|
|
1338
|
+
changedView.removeOnLayoutChangeListener(this)
|
|
1339
|
+
val deltaX = previousX - changedView.x
|
|
1340
|
+
val deltaY = previousY - changedView.y
|
|
1341
|
+
if (deltaX == 0f && deltaY == 0f) return
|
|
1342
|
+
changedView.animate().cancel()
|
|
1343
|
+
changedView.translationX = deltaX
|
|
1344
|
+
changedView.translationY = deltaY
|
|
1345
|
+
changedView.animate()
|
|
1346
|
+
.translationX(0f)
|
|
1347
|
+
.translationY(0f)
|
|
1348
|
+
.setDuration(REORDER_SPRING_DURATION_MS)
|
|
1349
|
+
.setInterpolator(reorderSpringInterpolator)
|
|
1350
|
+
.start()
|
|
1351
|
+
}
|
|
1352
|
+
})
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
private fun reorderActiveBackground(theme: JSONObject?): Int = try {
|
|
1356
|
+
parseNativeListColor(
|
|
1357
|
+
theme?.optString("rowPressedBackground", "#00000017") ?: "#00000017",
|
|
1358
|
+
)
|
|
1359
|
+
} catch (_: IllegalArgumentException) {
|
|
1360
|
+
parseNativeListColor("#00000017")
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
private fun scheduleVisibleEvent() {
|
|
1364
|
+
if (visibleEventScheduled) return
|
|
1365
|
+
visibleEventScheduled = true
|
|
1366
|
+
Choreographer.getInstance().postFrameCallback {
|
|
1367
|
+
visibleEventScheduled = false
|
|
1368
|
+
val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return@postFrameCallback
|
|
1369
|
+
val first = manager.findFirstVisibleItemPosition()
|
|
1370
|
+
val last = manager.findLastVisibleItemPosition()
|
|
1371
|
+
val firstKey = adapter.itemAt(first)?.key
|
|
1372
|
+
val lastKey = adapter.itemAt(last)?.key
|
|
1373
|
+
val signature = "$first:$last:${firstKey.orEmpty()}:${lastKey.orEmpty()}"
|
|
1374
|
+
if (signature == lastVisibleRangeSignature) return@postFrameCallback
|
|
1375
|
+
lastVisibleRangeSignature = signature
|
|
1376
|
+
val payload = JSONObject()
|
|
1377
|
+
.put("firstIndex", first)
|
|
1378
|
+
.put("lastIndex", last)
|
|
1379
|
+
firstKey?.let { payload.put("firstKey", it) }
|
|
1380
|
+
lastKey?.let { payload.put("lastKey", it) }
|
|
1381
|
+
emit(VISIBLE_RANGE_CHANGED, payload)
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
private fun checkEndReached() {
|
|
1386
|
+
val current = config ?: return
|
|
1387
|
+
if (!current.loadMore || current.generation == endReachedGeneration || adapter.itemCount == 0) return
|
|
1388
|
+
val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
|
|
1389
|
+
val lastVisible = manager.findLastVisibleItemPosition()
|
|
1390
|
+
val thresholdItems = max(1, ceil(adapter.itemCount * current.endReachedThreshold).toInt())
|
|
1391
|
+
if (lastVisible >= adapter.itemCount - thresholdItems) {
|
|
1392
|
+
endReachedGeneration = current.generation
|
|
1393
|
+
val payload = JSONObject().put("generation", current.generation)
|
|
1394
|
+
adapter.itemAt(adapter.itemCount - 1)?.let { payload.put("lastKey", it.key) }
|
|
1395
|
+
emit(END_REACHED, payload)
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
private fun emit(eventName: String, payload: JSONObject) {
|
|
1400
|
+
val json = payload.toString()
|
|
1401
|
+
when (eventName) {
|
|
1402
|
+
ROW_ACTION -> onRowAction?.invoke(json)
|
|
1403
|
+
ACTION_ANCHOR_INVALIDATED -> onActionAnchorInvalidated?.invoke(json)
|
|
1404
|
+
SELECTION_DELTA -> onSelectionDelta?.invoke(json)
|
|
1405
|
+
REORDER -> onReorder?.invoke(json)
|
|
1406
|
+
END_REACHED -> onEndReached?.invoke(json)
|
|
1407
|
+
VISIBLE_RANGE_CHANGED -> onVisibleRangeChanged?.invoke(json)
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* React Native owns the outer view's layout pass and does not always honor a
|
|
1413
|
+
* nested RecyclerView's requestLayout after the first mount. Re-run this
|
|
1414
|
+
* already-sized host once after a committed data update so the native rows
|
|
1415
|
+
* are measured and rebound without rebuilding the adapter.
|
|
1416
|
+
*/
|
|
1417
|
+
private fun relayoutContents() {
|
|
1418
|
+
post {
|
|
1419
|
+
if (disposed || width <= 0 || height <= 0) return@post
|
|
1420
|
+
forceLayout()
|
|
1421
|
+
measure(
|
|
1422
|
+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
1423
|
+
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
|
|
1424
|
+
)
|
|
1425
|
+
layout(left, top, right, bottom)
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
private fun relayoutRecyclerView() {
|
|
1430
|
+
recyclerView.post {
|
|
1431
|
+
if (disposed || recyclerView.width <= 0 || recyclerView.height <= 0) return@post
|
|
1432
|
+
recyclerView.forceLayout()
|
|
1433
|
+
recyclerView.measure(
|
|
1434
|
+
MeasureSpec.makeMeasureSpec(recyclerView.width, MeasureSpec.EXACTLY),
|
|
1435
|
+
MeasureSpec.makeMeasureSpec(recyclerView.height, MeasureSpec.EXACTLY),
|
|
1436
|
+
)
|
|
1437
|
+
recyclerView.layout(
|
|
1438
|
+
recyclerView.left,
|
|
1439
|
+
recyclerView.top,
|
|
1440
|
+
recyclerView.right,
|
|
1441
|
+
recyclerView.bottom,
|
|
1442
|
+
)
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
private fun relayoutRecyclerViewImmediately() {
|
|
1447
|
+
if (disposed || recyclerView.isComputingLayout || recyclerView.width <= 0 || recyclerView.height <= 0) {
|
|
1448
|
+
return
|
|
1449
|
+
}
|
|
1450
|
+
recyclerView.forceLayout()
|
|
1451
|
+
recyclerView.measure(
|
|
1452
|
+
MeasureSpec.makeMeasureSpec(recyclerView.width, MeasureSpec.EXACTLY),
|
|
1453
|
+
MeasureSpec.makeMeasureSpec(recyclerView.height, MeasureSpec.EXACTLY),
|
|
1454
|
+
)
|
|
1455
|
+
recyclerView.layout(
|
|
1456
|
+
recyclerView.left,
|
|
1457
|
+
recyclerView.top,
|
|
1458
|
+
recyclerView.right,
|
|
1459
|
+
recyclerView.bottom,
|
|
1460
|
+
)
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
private fun dp(value: Int): Int = NativeListScale.dp(resources, value)
|
|
1464
|
+
|
|
1465
|
+
companion object {
|
|
1466
|
+
private const val REORDER_LONG_PRESS_MS = 200L
|
|
1467
|
+
private const val REORDER_ALLOWABLE_MOVEMENT_DP = 10
|
|
1468
|
+
private const val REORDER_PLACEHOLDER_INSET_DP = 8
|
|
1469
|
+
private const val REORDER_PLACEHOLDER_RADIUS_DP = 12
|
|
1470
|
+
private const val REORDER_SPRING_DAMPING = 25.0
|
|
1471
|
+
private const val REORDER_SPRING_STIFFNESS = 400.0
|
|
1472
|
+
private const val REORDER_SPRING_MASS = 0.4
|
|
1473
|
+
private const val REORDER_SPRING_DURATION_MS = 300L
|
|
1474
|
+
private const val ROW_ACTION = "rowAction"
|
|
1475
|
+
private const val ACTION_ANCHOR_INVALIDATED = "actionAnchorInvalidated"
|
|
1476
|
+
private const val SELECTION_DELTA = "selectionDelta"
|
|
1477
|
+
private const val REORDER = "reorder"
|
|
1478
|
+
private const val END_REACHED = "endReached"
|
|
1479
|
+
private const val VISIBLE_RANGE_CHANGED = "visibleRangeChanged"
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
private class ReorderPlaceholderDecoration(
|
|
1484
|
+
private val adapter: NativeListAdapter,
|
|
1485
|
+
private val insetPx: Int,
|
|
1486
|
+
private val radiusPx: Float,
|
|
1487
|
+
) : RecyclerView.ItemDecoration() {
|
|
1488
|
+
var position: Int = RecyclerView.NO_POSITION
|
|
1489
|
+
var color: Int = Color.TRANSPARENT
|
|
1490
|
+
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
1491
|
+
private val bounds = RectF()
|
|
1492
|
+
|
|
1493
|
+
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
|
1494
|
+
val item = adapter.itemAt(position) ?: return
|
|
1495
|
+
val isWalletSidebar =
|
|
1496
|
+
item.type == "identity" && item.json.optString("presentation") == "walletSidebar"
|
|
1497
|
+
if (!isWalletSidebar && item.type != "walletGroup") return
|
|
1498
|
+
val view = parent.findViewHolderForAdapterPosition(position)?.itemView ?: return
|
|
1499
|
+
bounds.set(
|
|
1500
|
+
(view.left + insetPx).toFloat(),
|
|
1501
|
+
view.top.toFloat(),
|
|
1502
|
+
(view.right - insetPx).toFloat(),
|
|
1503
|
+
view.bottom.toFloat(),
|
|
1504
|
+
)
|
|
1505
|
+
paint.color = color
|
|
1506
|
+
canvas.drawRoundRect(bounds, radiusPx, radiusPx, paint)
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
private class ReorderSpringInterpolator(
|
|
1511
|
+
damping: Double,
|
|
1512
|
+
stiffness: Double,
|
|
1513
|
+
mass: Double,
|
|
1514
|
+
private val durationSeconds: Double,
|
|
1515
|
+
) : TimeInterpolator {
|
|
1516
|
+
private val naturalFrequency = sqrt(stiffness / mass)
|
|
1517
|
+
private val dampingRatio = damping / (2 * sqrt(stiffness * mass))
|
|
1518
|
+
private val dampedFrequency = naturalFrequency * sqrt(1 - dampingRatio * dampingRatio)
|
|
1519
|
+
|
|
1520
|
+
override fun getInterpolation(input: Float): Float {
|
|
1521
|
+
if (input <= 0f) return 0f
|
|
1522
|
+
if (input >= 1f) return 1f
|
|
1523
|
+
val time = input * durationSeconds
|
|
1524
|
+
val decay = exp(-dampingRatio * naturalFrequency * time)
|
|
1525
|
+
val displacement = decay * (
|
|
1526
|
+
cos(dampedFrequency * time) +
|
|
1527
|
+
dampingRatio * naturalFrequency / dampedFrequency * sin(dampedFrequency * time)
|
|
1528
|
+
)
|
|
1529
|
+
return (1 - displacement).toFloat()
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
private data class NativeListSectionIndexEntry(
|
|
1534
|
+
val key: String,
|
|
1535
|
+
val title: String,
|
|
1536
|
+
val position: Int,
|
|
1537
|
+
)
|
|
1538
|
+
|
|
1539
|
+
private class NativeListSectionIndexView(
|
|
1540
|
+
context: android.content.Context,
|
|
1541
|
+
) : View(context) {
|
|
1542
|
+
var onSelect: ((Int, Boolean) -> Unit)? = null
|
|
1543
|
+
var onInteractionEnded: (() -> Unit)? = null
|
|
1544
|
+
var activeIndex: Int? = null
|
|
1545
|
+
private set
|
|
1546
|
+
private var titles: List<String> = emptyList()
|
|
1547
|
+
private var normalColor = Color.GRAY
|
|
1548
|
+
private var activeColor = Color.BLACK
|
|
1549
|
+
private var lastTouchIndex: Int? = null
|
|
1550
|
+
private val normalPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
1551
|
+
textAlign = Paint.Align.CENTER
|
|
1552
|
+
typeface = NativeListFonts.medium(context)
|
|
1553
|
+
}
|
|
1554
|
+
private val activePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
1555
|
+
textAlign = Paint.Align.CENTER
|
|
1556
|
+
typeface = NativeListFonts.semibold(context)
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
init {
|
|
1560
|
+
isClickable = true
|
|
1561
|
+
isFocusable = true
|
|
1562
|
+
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
|
|
1563
|
+
contentDescription = ACCESSIBILITY_LABEL
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
fun configure(titles: List<String>, normalColor: Int, activeColor: Int) {
|
|
1567
|
+
this.titles = titles
|
|
1568
|
+
this.normalColor = normalColor
|
|
1569
|
+
this.activeColor = activeColor
|
|
1570
|
+
activeIndex = null
|
|
1571
|
+
updateContentDescription()
|
|
1572
|
+
invalidate()
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
fun setActiveIndex(index: Int?) {
|
|
1576
|
+
if (activeIndex == index) return
|
|
1577
|
+
activeIndex = index
|
|
1578
|
+
updateContentDescription()
|
|
1579
|
+
invalidate()
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
override fun onDraw(canvas: Canvas) {
|
|
1583
|
+
super.onDraw(canvas)
|
|
1584
|
+
if (titles.isEmpty()) return
|
|
1585
|
+
val cellHeight = cellHeight()
|
|
1586
|
+
val originY = (height - cellHeight * titles.size) / 2f
|
|
1587
|
+
val textSize = NativeListScale.font(resources, 10f) * resources.displayMetrics.scaledDensity
|
|
1588
|
+
normalPaint.color = normalColor
|
|
1589
|
+
normalPaint.textSize = textSize
|
|
1590
|
+
activePaint.color = activeColor
|
|
1591
|
+
activePaint.textSize = textSize
|
|
1592
|
+
titles.forEachIndexed { index, title ->
|
|
1593
|
+
val paint = if (index == activeIndex) activePaint else normalPaint
|
|
1594
|
+
val centerY = originY + cellHeight * (index + 0.5f)
|
|
1595
|
+
val baseline = centerY - (paint.descent() + paint.ascent()) / 2f
|
|
1596
|
+
canvas.drawText(title, width / 2f, baseline, paint)
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
1601
|
+
if (titles.isEmpty() || !isEnabled) return false
|
|
1602
|
+
when (event.actionMasked) {
|
|
1603
|
+
MotionEvent.ACTION_DOWN -> {
|
|
1604
|
+
parent?.requestDisallowInterceptTouchEvent(true)
|
|
1605
|
+
lastTouchIndex = null
|
|
1606
|
+
selectAt(event.y, interacting = true)
|
|
1607
|
+
return true
|
|
1608
|
+
}
|
|
1609
|
+
MotionEvent.ACTION_MOVE -> {
|
|
1610
|
+
selectAt(event.y, interacting = true)
|
|
1611
|
+
return true
|
|
1612
|
+
}
|
|
1613
|
+
MotionEvent.ACTION_UP -> {
|
|
1614
|
+
selectAt(event.y, interacting = true)
|
|
1615
|
+
lastTouchIndex = null
|
|
1616
|
+
parent?.requestDisallowInterceptTouchEvent(false)
|
|
1617
|
+
performClick()
|
|
1618
|
+
onInteractionEnded?.invoke()
|
|
1619
|
+
return true
|
|
1620
|
+
}
|
|
1621
|
+
MotionEvent.ACTION_CANCEL -> {
|
|
1622
|
+
lastTouchIndex = null
|
|
1623
|
+
parent?.requestDisallowInterceptTouchEvent(false)
|
|
1624
|
+
onInteractionEnded?.invoke()
|
|
1625
|
+
return true
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
return super.onTouchEvent(event)
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
override fun performClick(): Boolean {
|
|
1632
|
+
super.performClick()
|
|
1633
|
+
return true
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
|
|
1637
|
+
super.onInitializeAccessibilityNodeInfo(info)
|
|
1638
|
+
info.className = SeekBar::class.java.name
|
|
1639
|
+
info.isScrollable = titles.size > 1
|
|
1640
|
+
if (titles.isNotEmpty()) {
|
|
1641
|
+
info.rangeInfo = AccessibilityNodeInfo.RangeInfo.obtain(
|
|
1642
|
+
AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_INT,
|
|
1643
|
+
0f,
|
|
1644
|
+
(titles.size - 1).toFloat(),
|
|
1645
|
+
(activeIndex ?: 0).toFloat(),
|
|
1646
|
+
)
|
|
1647
|
+
info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
|
|
1648
|
+
info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD)
|
|
1649
|
+
info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_PROGRESS)
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
override fun performAccessibilityAction(action: Int, arguments: Bundle?): Boolean {
|
|
1654
|
+
if (titles.isEmpty()) return super.performAccessibilityAction(action, arguments)
|
|
1655
|
+
val next = when (action) {
|
|
1656
|
+
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD ->
|
|
1657
|
+
((activeIndex ?: -1) + 1).coerceAtMost(titles.lastIndex)
|
|
1658
|
+
AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD ->
|
|
1659
|
+
((activeIndex ?: 1) - 1).coerceAtLeast(0)
|
|
1660
|
+
AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_PROGRESS.id ->
|
|
1661
|
+
arguments?.getFloat(AccessibilityNodeInfo.ACTION_ARGUMENT_PROGRESS_VALUE)?.roundToInt()
|
|
1662
|
+
?.coerceIn(0, titles.lastIndex)
|
|
1663
|
+
else -> null
|
|
1664
|
+
} ?: return super.performAccessibilityAction(action, arguments)
|
|
1665
|
+
select(next, interacting = false)
|
|
1666
|
+
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED)
|
|
1667
|
+
return true
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
private fun selectAt(y: Float, interacting: Boolean) {
|
|
1671
|
+
val cellHeight = cellHeight()
|
|
1672
|
+
val originY = (height - cellHeight * titles.size) / 2f
|
|
1673
|
+
val index = ((y - originY) / cellHeight).toInt().coerceIn(0, titles.lastIndex)
|
|
1674
|
+
if (interacting && lastTouchIndex == index) return
|
|
1675
|
+
lastTouchIndex = index.takeIf { interacting }
|
|
1676
|
+
select(index, interacting)
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
private fun select(index: Int, interacting: Boolean) {
|
|
1680
|
+
onSelect?.invoke(index, interacting)
|
|
1681
|
+
setActiveIndex(index)
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
private fun cellHeight(): Float =
|
|
1685
|
+
(height.toFloat() / titles.size.coerceAtLeast(1))
|
|
1686
|
+
.coerceAtMost(NativeListScale.dp(resources, 16f))
|
|
1687
|
+
.coerceAtLeast(1f)
|
|
1688
|
+
|
|
1689
|
+
private fun updateContentDescription() {
|
|
1690
|
+
contentDescription = activeIndex?.let { titles.getOrNull(it) }
|
|
1691
|
+
?.let { "$ACCESSIBILITY_LABEL, $it" }
|
|
1692
|
+
?: ACCESSIBILITY_LABEL
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
companion object {
|
|
1696
|
+
private const val ACCESSIBILITY_LABEL = "Section index"
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
private class ItemSpacingDecoration(private val spacing: Int) : RecyclerView.ItemDecoration() {
|
|
1701
|
+
override fun getItemOffsets(
|
|
1702
|
+
outRect: android.graphics.Rect,
|
|
1703
|
+
view: View,
|
|
1704
|
+
parent: RecyclerView,
|
|
1705
|
+
state: RecyclerView.State,
|
|
1706
|
+
) {
|
|
1707
|
+
if (spacing <= 0) return
|
|
1708
|
+
val horizontal = (parent.layoutManager as? LinearLayoutManager)?.orientation == RecyclerView.HORIZONTAL
|
|
1709
|
+
if (horizontal) outRect.right = spacing else outRect.bottom = spacing
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
private class StickySectionHeaderDecoration(
|
|
1714
|
+
private val adapter: NativeListAdapter,
|
|
1715
|
+
private val context: android.content.Context,
|
|
1716
|
+
theme: JSONObject?,
|
|
1717
|
+
private val density: Float,
|
|
1718
|
+
) : RecyclerView.ItemDecoration() {
|
|
1719
|
+
private val backgroundPaint = Paint().apply {
|
|
1720
|
+
color = parseColor(theme?.optString("rowBackground"), "#FFFFFF")
|
|
1721
|
+
}
|
|
1722
|
+
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
1723
|
+
color = parseColor(theme?.optString("secondaryText"), "#0000009B")
|
|
1724
|
+
textSize = NativeListScale.font(context.resources, 14f) * density
|
|
1725
|
+
typeface = NativeListFonts.semibold(context)
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
|
1729
|
+
val manager = parent.layoutManager as? LinearLayoutManager ?: return
|
|
1730
|
+
val first = manager.findFirstVisibleItemPosition()
|
|
1731
|
+
if (first == RecyclerView.NO_POSITION) return
|
|
1732
|
+
var header: NativeListItem? = null
|
|
1733
|
+
for (index in first downTo 0) {
|
|
1734
|
+
val candidate = adapter.itemAt(index)
|
|
1735
|
+
if (candidate?.type == "sectionHeader") {
|
|
1736
|
+
if (candidate.json.optString("variant") == "summary") continue
|
|
1737
|
+
header = candidate.takeIf(::isSimpleStickySectionHeader)
|
|
1738
|
+
break
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
val item = header ?: return
|
|
1742
|
+
val isHistory = item.json.optString("variant") == "history" ||
|
|
1743
|
+
item.sectionKey?.startsWith("history-") == true
|
|
1744
|
+
val height = NativeListScale.dp(context.resources, if (isHistory) 16 else 36)
|
|
1745
|
+
val textSize = NativeListScale.font(context.resources, if (isHistory) 12f else 14f)
|
|
1746
|
+
textPaint.textSize = textSize * density
|
|
1747
|
+
val horizontalInset = NativeListScale.dp(context.resources, if (isHistory) 8 else 20).toFloat()
|
|
1748
|
+
val left = parent.paddingLeft.toFloat()
|
|
1749
|
+
val right = (parent.width - parent.paddingRight).toFloat()
|
|
1750
|
+
canvas.drawRect(left, 0f, right, height.toFloat(), backgroundPaint)
|
|
1751
|
+
val baseline = height / 2f - (textPaint.descent() + textPaint.ascent()) / 2f
|
|
1752
|
+
val value = item.json.optString("title").let { if (isHistory) it.uppercase() else it }
|
|
1753
|
+
val isRightToLeft = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL
|
|
1754
|
+
val textWidth = if (isHistory) {
|
|
1755
|
+
spacedTextWidth(value, NativeListScale.dp(context.resources, 1).toFloat())
|
|
1756
|
+
} else {
|
|
1757
|
+
textPaint.measureText(value)
|
|
1758
|
+
}
|
|
1759
|
+
val x = if (isRightToLeft) right - horizontalInset - textWidth else left + horizontalInset
|
|
1760
|
+
if (isHistory) {
|
|
1761
|
+
drawSpacedText(canvas, value, x, baseline, NativeListScale.dp(context.resources, 1).toFloat())
|
|
1762
|
+
} else {
|
|
1763
|
+
canvas.drawText(value, x, baseline, textPaint)
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
private fun drawSpacedText(canvas: Canvas, value: String, x: Float, baseline: Float, spacing: Float) {
|
|
1768
|
+
var cursor = x
|
|
1769
|
+
value.forEachIndexed { index, character ->
|
|
1770
|
+
val glyph = character.toString()
|
|
1771
|
+
canvas.drawText(glyph, cursor, baseline, textPaint)
|
|
1772
|
+
cursor += textPaint.measureText(glyph)
|
|
1773
|
+
if (index < value.lastIndex) cursor += spacing * 0.8f
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
private fun spacedTextWidth(value: String, spacing: Float): Float =
|
|
1778
|
+
value.sumOf { textPaint.measureText(it.toString()).toDouble() }.toFloat() +
|
|
1779
|
+
max(0, value.length - 1) * spacing * 0.8f
|
|
1780
|
+
|
|
1781
|
+
companion object {
|
|
1782
|
+
private fun parseColor(value: String?, fallback: String): Int = try {
|
|
1783
|
+
parseNativeListColor(if (value.isNullOrEmpty()) fallback else value)
|
|
1784
|
+
} catch (_: IllegalArgumentException) {
|
|
1785
|
+
parseNativeListColor(fallback)
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
internal fun isSimpleStickySectionHeader(item: NativeListItem): Boolean =
|
|
1791
|
+
item.type == "sectionHeader" &&
|
|
1792
|
+
item.json.optString("variant") != "summary" &&
|
|
1793
|
+
item.json.optString("value").isEmpty() &&
|
|
1794
|
+
item.json.optJSONObject("checkbox") == null
|