20260621
读者应具备的知识基础
Kotlin、 Compose、 基础Android开发(谷歌官网课程优先)
关于HorizontalPager的前文勘误
Android studio 升级至2026.1.1 Patch 1以后,Gemini能力似乎有所提高。同时,新的用量可视化UI很不错,可以更好地安排白嫖了:)

之前文章中我的测试不够严谨,实际上对于HorizontalPager,由于放大效果是用Modifier.graphicsLayer实现的,这只是改变视觉效果,本身不改变UI控件大小,因此同样可以实现在图片放大后越界翻到下一页。效果像是这样:

这是通过写一点小思路,让升级后的Gemini Vibe coding完成的,过程不值一提,其相关代码如下:
kotlin
@Composable
private fun HorizontalPagingLayout(
settingsUiState: BrowserSettingsUiState,
maxPageIndexSize: Int,
currentPageIndex: Int,
readingDirection: ReadingDirection,
showMenu: Boolean,
onToggleMenu: () -> Unit,
onPageChanged: (Int) -> Unit,
onShowMessage: (String) -> Unit,
getComicPage: suspend (Int) -> ImageBitmap?
) {
val pagerState = rememberPagerState(
initialPage = currentPageIndex,
pageCount = { maxPageIndexSize }
)
val coroutineScope = rememberCoroutineScope()
val viewConfiguration = LocalViewConfiguration.current
val reachedFirstPageText = stringResource(R.string.reached_first_page)
val reachedLastPageText = stringResource(R.string.reached_last_page)
var lastBoundaryMessageTime by remember { mutableLongStateOf(0L) }
val nestedScrollConnection = remember(readingDirection) {
val isRtl = readingDirection == ReadingDirection.RIGHT_TO_LEFT
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
if (source == NestedScrollSource.UserInput && abs(available.x) > 3f) {
val now = System.currentTimeMillis()
if (now - lastBoundaryMessageTime > 2500) {
// available.x > 0 是物理左边界,available.x < 0 是物理右边界
val isAtFirstPage = if (isRtl) available.x < 0 else available.x > 0
onShowMessage(if (isAtFirstPage) reachedFirstPageText else reachedLastPageText)
lastBoundaryMessageTime = now
}
}
return Offset.Zero
}
}
}
// 同步外部页码变化到 Pager (如 Slider 操作)
LaunchedEffect(currentPageIndex) {
if (currentPageIndex != pagerState.currentPage) {
pagerState.scrollToPage(currentPageIndex)
}
}
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
var isInteracting by remember { mutableStateOf(false) }
// 🌟 记录当前正在"放大"的页码
var scaledPageIndex by remember { mutableIntStateOf(pagerState.currentPage) }
// 🌟 只有当翻页彻底结束且停稳时,才重记当前页码并重置缩放状态
LaunchedEffect(pagerState.currentPage, pagerState.isScrollInProgress, isInteracting) {
// ⚡ 核心修复:只有在不滚动且偏移量归零时才重置,防止慢滑超过 50% 时重置
if (!isInteracting && !pagerState.isScrollInProgress &&
abs(pagerState.currentPageOffsetFraction) < 0.001f &&
pagerState.currentPage != scaledPageIndex
) {
onPageChanged(pagerState.currentPage)
scale = 1f
offsetX = 0f
offsetY = 0f
scaledPageIndex = pagerState.currentPage
}
}
val isScaled by remember { derivedStateOf { scale > 1f } }
val currentAnimJob = remember { mutableStateOf<Job?>(null) }
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(showMenu) {
detectTapGestures(
onDoubleTap = { tapOffset ->
if (showMenu || !settingsUiState.doubleTapToZoom) return@detectTapGestures
// 🌟 手势开始前:如果当前没放大,确保缩放作用在当前视野中心页
if (scale <= 1f) {
scaledPageIndex = pagerState.currentPage
}
currentAnimJob.value?.cancel()
currentAnimJob.value = coroutineScope.launch {
if (scale > 1f) {
val initialScale = scale
val initialX = offsetX
val initialY = offsetY
AnimationState(initialValue = 0f).animateTo(1f) {
scale = initialScale + (1f - initialScale) * this.value
offsetX = initialX * (1f - this.value)
offsetY = initialY * (1f - this.value)
}
} else {
val targetScale = settingsUiState.doubleTapZoomRatio
val cx = size.width / 2f
val cy = size.height / 2f
val boundX = max(0f, (size.width * targetScale - size.width) / 2f)
val boundY = max(0f, (size.height * targetScale - size.height) / 2f)
val targetX =
((cx - tapOffset.x) * targetScale).coerceIn(-boundX, boundX)
val targetY =
((cy - tapOffset.y) * targetScale).coerceIn(-boundY, boundY)
val initialScale = scale
val initialX = offsetX
val initialY = offsetY
AnimationState(initialValue = 0f).animateTo(1f) {
scale = initialScale + (targetScale - initialScale) * this.value
offsetX = initialX + (targetX - initialX) * this.value
offsetY = initialY + (targetY - initialY) * this.value
}
}
}
},
onTap = { tapOffset ->
if (showMenu) {
onToggleMenu()
return@detectTapGestures
}
val screenWidth = size.width
val screenHeight = size.height
val x = tapOffset.x
val y = tapOffset.y
val isRtl = readingDirection == ReadingDirection.RIGHT_TO_LEFT
when (settingsUiState.tapToTurnPageMode) {
TapToTurnPageMode.TOP_BOTTOM -> {
when {
y < screenHeight / 3f -> {
if (!isScaled) {
if (pagerState.canScrollBackward) {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
} else {
onShowMessage(reachedFirstPageText)
}
}
}
y > screenHeight * 2 / 3f -> {
if (!isScaled) {
if (pagerState.canScrollForward) {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
} else {
onShowMessage(reachedLastPageText)
}
}
}
else -> onToggleMenu()
}
}
TapToTurnPageMode.LEFT_RIGHT -> {
val isPrev =
if (isRtl) x > screenWidth * 2 / 3f else x < screenWidth / 3f
val isNext =
if (isRtl) x < screenWidth / 3f else x > screenWidth * 2 / 3f
when {
isPrev -> {
if (!isScaled) {
if (pagerState.canScrollBackward) {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
} else {
onShowMessage(reachedFirstPageText)
}
}
}
isNext -> {
if (!isScaled) {
if (pagerState.canScrollForward) {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
} else {
onShowMessage(reachedLastPageText)
}
}
}
else -> onToggleMenu()
}
}
TapToTurnPageMode.NONE -> {
if (x in (screenWidth / 3f)..(screenWidth * 2 / 3f) &&
y in (screenHeight / 3f)..(screenHeight * 2 / 3f)
) {
onToggleMenu()
}
}
}
}
)
}
.pointerInput(showMenu) {
if (showMenu) return@pointerInput
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
currentAnimJob.value?.cancel()
}
}
.pointerInput(showMenu) {
if (showMenu) return@pointerInput
awaitEachGesture {
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
var accumulatedZoom = 1f
var accumulatedPan = Offset.Zero
awaitFirstDown(requireUnconsumed = false)
isInteracting = true
try {
// 🌟 双指操作前:同步缩放目标
if (scale <= 1f) {
scaledPageIndex = pagerState.currentPage
}
do {
val event = awaitPointerEvent()
if (event.changes.size > 1) {
val zoomChange = event.calculateZoom()
val panChange = event.calculatePan()
val centroid = event.calculateCentroid(useCurrent = true)
if (!pastTouchSlop) {
accumulatedZoom *= zoomChange
accumulatedPan += panChange
val centroidSize =
event.calculateCentroidSize(useCurrent = false)
if (abs(1 - accumulatedZoom) * centroidSize > touchSlop ||
accumulatedPan.getDistance() > touchSlop
) pastTouchSlop = true
}
if (pastTouchSlop) {
val oldScale = scale
val newScale =
(scale * zoomChange).coerceIn(1f, MAX_SCALE_RATIO)
if (newScale > 1f || oldScale > 1f) {
val boundX =
max(0f, (size.width * newScale - size.width) / 2f)
val boundY =
max(0f, (size.height * newScale - size.height) / 2f)
if (centroid != Offset.Unspecified) {
val cx = size.width / 2f
val cy = size.height / 2f
offsetX =
(offsetX * zoomChange + (centroid.x - cx) * (1 - zoomChange) + panChange.x)
.coerceIn(-boundX, boundX)
offsetY =
(offsetY * zoomChange + (centroid.y - cy) * (1 - zoomChange) + panChange.y)
.coerceIn(-boundY, boundY)
}
scale = newScale
} else {
scale = 1f; offsetX = 0f; offsetY = 0f
}
}
event.changes.forEach { if (it.positionChanged()) it.consume() }
}
} while (event.changes.any { it.pressed })
} finally {
isInteracting = false
}
}
}
.pointerInput(isScaled, showMenu) {
if (!isScaled || showMenu) return@pointerInput
val decaySpec = splineBasedDecay<Float>(this)
val velocityTracker = VelocityTracker()
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
isInteracting = true
try {
velocityTracker.resetTracking()
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
var accumulatedPan = Offset.Zero
var isCrossingBoundary = false
do {
val event = awaitPointerEvent()
// ⚡【核心隔离】:如果发现多于一根手指,单指滑动逻辑立刻自动退出,由多指块接管
if (event.changes.size > 1) break
val change = event.changes.first()
val panChange = event.calculatePan()
if (!pastTouchSlop) {
accumulatedPan += panChange
if (accumulatedPan.getDistance() > touchSlop) pastTouchSlop = true
}
if (pastTouchSlop) {
velocityTracker.addPosition(change.uptimeMillis, change.position)
val boundX = max(0f, (size.width * scale - size.width) / 2f)
val boundY = max(0f, (size.height * scale - size.height) / 2f)
val isRtl = readingDirection == ReadingDirection.RIGHT_TO_LEFT
// ⚡ 核心逻辑:如果在跨界中,或者即将跨界
val expectedOffsetX = offsetX + panChange.x
val clampedX = expectedOffsetX.coerceIn(-boundX, boundX)
val overflowX = expectedOffsetX - clampedX
if (isCrossingBoundary || overflowX != 0f) {
isCrossingBoundary = true
// ⚡ 只要进入了跨界状态,后续的所有 x 轴位移都直接交给 Pager 处理
// 这样可以保证反向滑动(把下一页收回去)也是完全跟手的
val scrollDelta = if (isRtl) panChange.x else -panChange.x
pagerState.dispatchRawDelta(scrollDelta)
} else {
offsetX = clampedX
}
offsetY = (offsetY + panChange.y).coerceIn(-boundY, boundY)
if (change.positionChanged()) change.consume()
}
} while (event.changes.any { it.pressed })
if (isCrossingBoundary) {
val velocity = velocityTracker.calculateVelocity().x
coroutineScope.launch {
val isRtl = readingDirection == ReadingDirection.RIGHT_TO_LEFT
val offset = pagerState.currentPageOffsetFraction
// ⚡ 判定翻页目标:
// 1. 计算相对于起点(scaledPageIndex)的移动距离
val diff = pagerState.currentPage - scaledPageIndex
val progress = diff + offset
val targetPage = when {
abs(velocity) > 500f -> {
// 根据滑动速度方向判定翻页(LTR: 速度负向为前进,RTL: 速度正向为前进)
val isForward = if (isRtl) velocity > 0 else velocity < 0
if (isForward) scaledPageIndex + 1 else scaledPageIndex - 1
}
abs(progress) > 0.2f -> {
// 只要位移超过 20%,则向位移方向翻页
if (progress > 0) scaledPageIndex + 1 else scaledPageIndex - 1
}
else -> scaledPageIndex
}.coerceIn(0, maxPageIndexSize - 1)
pagerState.animateScrollToPage(targetPage)
}
return@awaitEachGesture
}
// 抬手后的惯性滑动逻辑
val velocity = velocityTracker.calculateVelocity()
if (abs(velocity.x) > 100f || abs(velocity.y) > 100f) {
currentAnimJob.value = coroutineScope.launch {
val boundX = max(0f, (size.width * scale - size.width) / 2f)
val boundY = max(0f, (size.height * scale - size.height) / 2f)
launch {
var lastX = offsetX
AnimationState(
initialValue = offsetX,
initialVelocity = velocity.x
)
.animateDecay(decaySpec) {
val delta = this.value - lastX
lastX = this.value
val expectedOffsetX = offsetX + delta
val clampedX = expectedOffsetX.coerceIn(-boundX, boundX)
val overflowX = expectedOffsetX - clampedX
if (overflowX != 0f) {
// 🌟 翻页模式优化:放大时不执行跨页惯性
offsetX = clampedX
if (abs(this.velocity) < 10f) cancelAnimation()
} else {
offsetX = clampedX
}
}
}
launch {
var lastY = offsetY
AnimationState(
initialValue = offsetY,
initialVelocity = velocity.y
)
.animateDecay(decaySpec) {
val delta = this.value - lastY
lastY = this.value
offsetY = (offsetY + delta).coerceIn(-boundY, boundY)
if ((offsetY <= -boundY || offsetY >= boundY) && abs(
this.velocity
) < 10f
) cancelAnimation()
}
}
}
}
} finally {
isInteracting = false
}
}
}
) {
HorizontalPager(
state = pagerState,
reverseLayout = readingDirection == ReadingDirection.RIGHT_TO_LEFT,
userScrollEnabled = !isScaled && !showMenu,
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
) { pageIndex ->
val state by produceState<BitmapLoadingState>(
initialValue = BitmapLoadingState.Loading,
pageIndex,
) {
val res = getComicPage(pageIndex)
value = if (null != res)
BitmapLoadingState.Success(res)
else
BitmapLoadingState.Failure
}
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
// 🌟 核心改进:仅对 scaledPageIndex 施加变换,确保滑入的新页始终是 1x 不变形
val isTarget = pageIndex == scaledPageIndex
scaleX = if (isTarget) scale else 1f
scaleY = if (isTarget) scale else 1f
translationX = if (isTarget) offsetX else 0f
translationY = if (isTarget) offsetY else 0f
},
contentAlignment = Alignment.Center
) {
when (state) {
is BitmapLoadingState.Loading -> {
CircularProgressIndicator(modifier = Modifier.size(Dimen.circularProgressIndicatorSize))
}
is BitmapLoadingState.Success -> {
val bitmap = (state as BitmapLoadingState.Success).bitmap
Image(
bitmap = bitmap,
contentScale = ContentScale.Fit,
contentDescription = null,
modifier = Modifier.fillMaxSize()
)
}
is BitmapLoadingState.Failure -> {
Icon(
painter = painterResource(R.drawable.broken_image_24dp),
contentDescription = null,
tint = Color.LightGray,
modifier = Modifier.size(48.dp)
)
}
}
}
}
}
}
目前已知问题:
- 放大后越界滑动,再反向滑动时,由于事件全交给了
HorizontalPager处理,因此另一侧的页面会显现出来,产生页面重叠的非预期效果。
关于自定义Compose的提示
跳过重复测量,仅仅触发布局
HorizontalPager对于实现自动单双页会非常困难,因此我们采用自定义Compose的方案。如果你熟悉自定义View,实际上也大差不差。
首先是阶段变化,View的三阶段测量、布局、绘制到Compose中对应为组合、测量及布局、绘制。
类似的阶段,定制方法却不同。比如对于测量、布局,自定义View中我们通过重写回调方法来完成对特定阶段的定制。而在Compose中,我们提供MeasurePolicy来完成测量及布局,比如一个支持滑动翻页的自定义Compose,MeasurePolicy的写法大体如下:
kotlin
Layout(content = content) { measurables, constraints ->
// #scope 1
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
layout(constraints.maxWidth, constraints.maxHeight) {
// #scope 2
// 来自 HorizontalPager 相关, PagerMeasure 的处理方案
state.attachInvalidatorToScope()
state.setWidth(placeables[0].width)
// logE("--- layout")
for (i in placeables.indices) {
val placeable = placeables[i]
val width = placeable.width
placeable.placeRelative(
x = state.offset + getInherentXOffset(i, state.currentMiddlePos, width),
y = 0
)
}
}
}
其中,scope 1完成测量,而scope 2完成布局。由于Compose的重组区域以Scope进行判定,因此我们可以跳过重复测量,仅仅触发布局来完成UI的位移变化。你可以参阅HorizontalPager的源码。简单来说,原理还是自动观察State变化并响应那一套。
首先,定义这样一个类:
kotlin
class OScopeInvalidator(
private val state: MutableState<Unit> = mutableStateOf(Unit, neverEqualPolicy())
) {
fun attachToScope() {
state.value
}
fun invalidateScope() {
state.value = Unit
}
}
然后,在scope 2内调用state.attachInvalidatorToScope(),而在滑动处理中,在state.offset变化后同步调用state.invalidateScope()即可触发再次布局。
注意代码调用点,防止跳过重组失效
如上节所述,我们的自定义UI组件固定提供三个页面循环利用。每个页面通过物理索引位置唯一确定。比如:初始状态下,012三页,0是左页,1是中间页,2是右页。再经历一次向右翻页后,结构变化为201,即2变为左页,0变为中间页,1变为右页。
请看以下两段通过物理索引位置确定载入页面的代码:
kotlin
repeat(3) { physicalSlot ->
when (getLocation(physicalSlot, state.currentMiddlePos)) {
PageLocation.LEFT -> {
val key = keyProducer(leftPageIndex)
ReusablePage(key, pageContent) // 调用点 A
}
PageLocation.MIDDLE -> {
val key = keyProducer(currentPageIndex)
ReusablePage(key, pageContent) // 调用点 B
}
PageLocation.RIGHT -> {
val key = keyProducer(rightPageIndex)
ReusablePage(key, pageContent) // 调用点 C
}
}
}
kotlin
repeat(3) { physicalSlot ->
// 1. 先计算出这个插槽对应的逻辑页码
val targetPageIndex = when (getLocation(physicalSlot, state.currentMiddlePos)) {
PageLocation.LEFT -> leftPageIndex
PageLocation.MIDDLE -> currentPageIndex
PageLocation.RIGHT -> rightPageIndex
}
// 2. 根据页码生成 Key
val key = keyProducer(targetPageIndex)
// 3. 在 when 分支外部统一调用(确保调用点 ID 固定)
ReusablePage(key, pageContent)
}
从逻辑上看,它们的效果一致。
但是,同样在key不变的情况下,第一段无法跳过重组,第二段则可以顺利跳过。
其中的原因,AI总结如下:
在 Compose 中,识别一个 Composable 函数是否可以"跳过"不仅取决于它的参数(Key)是否相等,还取决于它在 插槽表(Slot Table) 中的位置 ID。这个位置 ID 是由编译器根据源代码的结构生成的。
假设当前 physicalSlot 为 1。
- 1.翻页前:currentMiddlePos 为 1。getLocation(1, 1) 返回 MIDDLE。此时执行的是 调用点 B。Compose 在插槽表中记住了这个位置运行的是 key = B 的页面。
- 2.翻页后:currentMiddlePos 变为 2。getLocation(1, 2) 返回 LEFT。此时执行流进入了另一个分支,运行的是 调用点 A。
对于 Compose 编译器来说: 虽然函数名都是 ReusablePage,参数 key 也刚好还是 B,但 调用点 A 和 调用点 B 是两个完全不同的"身份"。
- Compose 看到"调用点 B"消失了,于是销毁它。
- Compose 看到"调用点 A"出现了,于是将其视为 首次组合(Initial Composition)。
- 既然是首次组合,ReusablePage 内部的代码逻辑就会完整运行一遍,表现出来就是"发生了重组"。
不要对ReusableContent传入null作为key
在自定义Compose中,为了极致性能,显然需要使用ReusableContent进行组件重用。其源码如下:
kotlin
@Composable
public inline fun ReusableContent(key: Any?, content: @Composable () -> Unit) {
currentComposer.startReusableGroup(reuseKey, key)
content()
currentComposer.endReusableGroup()
}
可以看到,其中的key参数是Any?类型,想当然地,我一开始设计了传入null用于指示空页面,结果报错了:
Error was captured in composition. androidx.compose.runtime.ComposeRuntimeError: Compose Runtime internal error. Unexpected or incorrect use of the Compose internal runtime API (Updating the data of a group that was not created with a data slot).
推测可能是自定义类型与null不可比产生的错误,我认为Google应当修改函数签名,去除可空。
解决方法也简单,就是在自定义类型中另外设计一个特殊值用于指示空页面。
自动单双页
Vibe coding悖论
旧有经验告诉我,这是个相当繁琐的功能。我们调整到Plan模式,看下AI有何高见。
先别写代码。做一个功能实现设计:在竖屏模式下的自动单页功能,当检测到页面是双页时,自动切分成左右两个单页。你详细给出实现原理的文档
完成之后,点击链接就能打开文档了。像是这样:

内容不多,也就一页半,十分粗糙,而且后面许多都是待实现的留空。如果让AI按这样施工,显然会出大问题。鼠标移到每一行上,右侧都是出现一个+按钮,可以添加评论,之后再让AI进一步完善。
对于这种非常小众的需求,AI的表现你只能自求多福。我们的项目是个个工具类App,根据我的经验,作为商业应用先不说比竞品体验更佳,即便不能持平,你也是基本争取不到付费用户。
也就是说,在这种核心功能上,你必须保证体验良好。
移动开发跟Web开发对于性能的要求完全就是两种情况,特别是图像处理是高负载操作,iOS或许还可以凭借强大机能硬吃,但在Android上你必须考虑你的广大受众机能一般。如果你有调研的话,你会发现付费榜第一的CDisplay性能表现就很好。
由于AI的设计实现完全达不到要求,我一开始有试图通过修改文档、追问来让工作推进下去------是的,我暂且忽略掉了即便给出完整实现方案,AI也无法实际落地的糟糕境地。但是越往下干,我越发感到情况不对:
- 精准描述需求文档,以及写好技术文档,本身就是非常不简单的工作。如果你的编程经历足够长,可能你也跟我一样比较随意平时压根就不写这种东西,都是把重难点记在脑中,代码上加点注释了事:(
- 与AI交流需要耗费巨大的沟通成本,如果是同做一个项目的人类工程师,彼此熟悉项目整体情况,几句话讲清的事,跟AI讲可能几百句都讲不清。
- 代码本身是凝练的------特别是像Kotlin这种现代语言,但自然语言却不然。有些时候,将一些技术细节不通过代码而是自然语言来描述,可能会成几何倍数暴增文本量。你问我为什么不直接通过代码描述?那如果已经有现成代码,我岂不是直接贴上去用就成了,还找AI干嘛呢?
最终的评估结果,我判定教会AI写这个功能需要耗费的时间精力,会远远大于我自己干。甚至其失败概率还相当高。
这显然成了个悖论:
使用Vibe coding本身是为了提升开发效率,但某些情况下使用Vibe coding却会严重拖慢效率
总结一下:对于特定的具有独创性的功能,以及性能敏感的领域,如果你发现Vibe coding自己干不下去,你最好先想清楚自己是否打算写出巨细靡遗的需求文档与技术文档,并承受AI得到它们也干不了活的后果。
自己动手
这个功能的难点在于情况相当繁琐。我们先实现竖屏下的自动单页,横屏下的自动双页也是类似的原理。
总体的思想是:我们设计一个PageItem用于存储物理页码pageIndex以及处理模式itemMode,再将这些PageItem装入一个List作为供UI使用的数据源。同时,由于我们需要处理向左、向右翻页两种模式,再设计一个ConcretePageItem用于实际加载页面使用。
kotlin
/**
* 列表中保存的项目
* */
data class PageItem(
val pageIndex: Int = 0,
val itemMode: ItemMode = ItemMode.ORIGIN,
)
enum class ItemMode {
ORIGIN, SINGLE_1, SINGLE_2, DUAL
}
fun PageItem.toConcretePageItem(isRtl: Boolean): ConcretePageItem {
return when (itemMode) {
ORIGIN -> ConcretePageItem(pageIndex, ConcreteItemMode.ORIGIN)
SINGLE_1 -> if (isRtl) ConcretePageItem(pageIndex, ConcreteItemMode.SINGLE_R)
else ConcretePageItem(pageIndex, ConcreteItemMode.SINGLE_L)
SINGLE_2 -> if (isRtl) ConcretePageItem(pageIndex, ConcreteItemMode.SINGLE_L)
else ConcretePageItem(pageIndex, ConcreteItemMode.SINGLE_R)
DUAL -> ConcretePageItem(pageIndex, ConcreteItemMode.DUAL)
}
}
/**
* 实际决定载入页面策略的项目
* */
data class ConcretePageItem(
val pageIndex: Int,
val itemMode: ConcreteItemMode,
)
enum class ConcreteItemMode {
ORIGIN, SINGLE_L, SINGLE_R, DUAL
}
显然,由于自动单页功能会将双页切分,也就是一个物理页面,会对应两个逻辑页面PageItem,我们的数据源List,会在进行自动单页处理后,长度增长。
比如:我们的自定义Compose初始加载左中右三个页面,每个页面的加载任务均通过ViewModel启动协程实现。这三个页面的每一个都是可被切分的物理双页,显然地,当翻页模式是向右翻页时,中间页切分出来的两个单页,其中的"右单页"应交给右侧页面显示。那么,这时候右侧页面原本启动的加载任务,其得到的结果就不应引发重组了。不过,如果三个协程完成的顺序不理想,极端情况下显然将重组三次。
接下来谈谈数据同步问题。
-
虽然Compose并发机制的饼Google画好多年了,至今仍未实现,但三个页面的载入均通过协程实现,当它们都要对
数据源List做更新操作时------比如上述例子中的情况,确实需要当心数据同步。为此,如果是在ViewModel中启动协程对数据源List做更新,注意要手动指定至Dispatcher.Main调度器,保证同步处理数据源List的更新。 -
另外,由于无法预估用户是否会切换
页面模式,因此即使是原始模式下的页码更新,也要同步更新自动单页模式下的数据源List中的列表位置,否则在模式切换后会产生位置不一致的问题。
接下来就看看效果吧:

似乎没什么问题,更详细的检查留待后续。
下一节,我们讨论一下自动双页的实现。