Skip to content

Commit

Permalink
optimize CodeEditorView and BigMonospaceText to reduce the number of …
Browse files Browse the repository at this point in the history
…text layout, so that text layout in the first rendering is already stable, and no need to wait for 200ms to render the first layout
  • Loading branch information
sunny-chung committed Nov 25, 2024
1 parent 666ad9c commit 9b20a7c
Show file tree
Hide file tree
Showing 6 changed files with 79 additions and 29 deletions.
Original file line number Diff line number Diff line change
@@ -1,39 +1,51 @@
package com.sunnychung.application.multiplatform.hellohttp.util

import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.sunnychung.lib.multiplatform.kdatetime.KDuration
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.distinctUntilChanged

/**
* @return pair of debounced state and "is the state latest"
*/
@OptIn(FlowPreview::class)
@Composable
fun <T> debouncedStateOf(interval: KDuration, stateProducer: () -> T): T {
fun <T> debouncedStateOf(interval: KDuration, tolerateCount: Int = 0, vararg cacheKeys: Any?, stateProducer: () -> T): Pair<T, Boolean> {
val currentState = stateProducer()
val coroutineScope = rememberCoroutineScope()
val flow = remember { MutableSharedFlow<T>(
replay = 1,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
) }
var stateRecord by remember { mutableStateOf(currentState) }
val flow = remember(*cacheKeys) { MutableSharedFlow<T>(
replay = 1,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
) }
var stateRecord by remember(*cacheKeys) { mutableStateOf(currentState) }
var stateCount by remember(*cacheKeys) { mutableStateOf(0) }
if (stateRecord != currentState) {
flow.tryEmit(currentState)
}
flow.debounce(interval.toMilliseconds())
.onEach {
stateRecord = it
++stateCount
flow.tryEmit(currentState).also {
log.v { "ds tryEmit($currentState) = $it. old = $stateRecord" }
}
if (stateCount <= tolerateCount) {
stateRecord = currentState
}
.launchIn(scope = coroutineScope)
return stateRecord
}
LaunchedEffect(flow) {
log.v { "ds launch flow" }
flow.distinctUntilChanged()
.debounce(interval.toMilliseconds())
.collect {
log.v { "ds collect state $it" }
stateRecord = it
}
}
return (stateRecord to (stateRecord == currentState)).also {
log.v { "ds state = $it, count = $stateCount" }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.regex.Pattern
import kotlin.math.abs
import kotlin.random.Random

val MAX_TEXT_FIELD_LENGTH = 4 * 1024 * 1024 // 4 MB
Expand Down Expand Up @@ -137,7 +138,10 @@ fun CodeEditorView(

var layoutResult by remember { mutableStateOf<BigTextSimpleLayoutResult?>(null) }

val bigTextFieldState: BigTextFieldState by rememberConcurrentLargeAnnotatedBigTextFieldState(initialValue = initialText, cacheKey)
val bigTextFieldState: BigTextFieldState by rememberConcurrentLargeAnnotatedBigTextFieldState(initialValue = initialText, cacheKey) {
log.d { "init BigText disable layout. lines = ${it.text.numOfLines}" }
it.viewState.isLayoutDisabled = true
}
val bigTextValue: BigText = bigTextFieldState.text
var bigTextValueId by remember(bigTextFieldState) { mutableStateOf<Long>(Random.nextLong()) }

Expand Down Expand Up @@ -481,7 +485,11 @@ fun CodeEditorView(
collapsedLines = collapsedLines.values.toList(),
onCollapseLine = onCollapseLine,
onExpandLine = onExpandLine,
modifier = Modifier.fillMaxHeight(),
onCorrectMeasured = {
log.v { "change isLayoutDisabled from ${bigTextFieldState.viewState.isLayoutDisabled} to false" }
bigTextFieldState.viewState.isLayoutDisabled = false
},
modifier = Modifier.fillMaxHeight()
)

if (isReadOnly) {
Expand Down Expand Up @@ -733,6 +741,7 @@ fun BigTextLineNumbersView(
collapsedLines: List<IntRange>,
onCollapseLine: (Int) -> Unit,
onExpandLine: (Int) -> Unit,
onCorrectMeasured: () -> Unit,
) = with(LocalDensity.current) {
val colours = LocalColor.current
val fonts = LocalFont.current
Expand Down Expand Up @@ -761,7 +770,7 @@ fun BigTextLineNumbersView(
firstRow = visibleRows.first,
lastRow = visibleRows.endInclusive + 1,
rowToLineIndex = { layoutText?.findOriginalLineIndexByRowIndex(it) ?: 0 },
totalLines = layoutText?.numOfOriginalLines ?: bigText.numOfLines,
totalLines = bigText.numOfLines,
lineHeight = (rowHeight).toDp(),
getRowOffset = {
(it * rowHeight - viewportTop).toDp()
Expand All @@ -770,6 +779,7 @@ fun BigTextLineNumbersView(
collapsedLinesState = collapsedLinesState,
onCollapseLine = onCollapseLine,
onExpandLine = onExpandLine,
onCorrectMeasured = onCorrectMeasured,
modifier = modifier
)
}
Expand All @@ -787,6 +797,7 @@ private fun CoreLineNumbersView(
collapsedLinesState: CollapsedLinesState,
onCollapseLine: (Int) -> Unit,
onExpandLine: (Int) -> Unit,
onCorrectMeasured: () -> Unit,
) = with(LocalDensity.current) {
val colours = LocalColor.current
val fonts = LocalFont.current
Expand All @@ -801,14 +812,23 @@ private fun CoreLineNumbersView(
maxOf(textMeasurer.measure("8".repeat(lineNumDigits), textStyle, maxLines = 1).size.width.toDp(), 20.dp) +
4.dp + (if (collapsableLines.isNotEmpty()) 24.dp else 0.dp) + 4.dp
}
log.v { "totalLines = $totalLines, width = ${width.toPx()}" }

Box(
modifier = modifier
.width(width)
.fillMaxHeight()
.clipToBounds()
.background(colours.backgroundLight)
.padding(top = 6.dp, start = 4.dp, end = 4.dp), // see AppTextField
.onGloballyPositioned { // need to be put before padding modifiers so that measured size includes padding
if (abs(it.size.width - width.toPx()) < 0.1 /* equivalent to it.size.width == width.toPx() */) {
log.v { "correct width ${it.size.width}. expected = ${width.toPx()}" }
onCorrectMeasured()
} else {
log.v { "reject width ${it.size.width}. expected = ${width.toPx()}" }
}
}
.padding(top = 6.dp, start = 4.dp, end = 4.dp) // see AppTextField
) {
var ii: Int = firstRow
var lastLineIndex = -1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ fun BodyViewerView(
val isEnableJsonPath = selectedView.name.contains("json", ignoreCase = true)
var jsonPathExpression by rememberLast(key) { mutableStateOf("") }
var isJsonPathError by rememberLast(key) { mutableStateOf(false) }
val debouncedJsonPathExpression = debouncedStateOf(400.milliseconds()) { jsonPathExpression }
val (debouncedJsonPathExpression, _) = debouncedStateOf(400.milliseconds()) { jsonPathExpression }

Column(modifier = modifier) {
Box(modifier = Modifier.fillMaxWidth()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
Expand Down Expand Up @@ -105,6 +106,7 @@ import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.random.Random
import kotlin.reflect.KMutableProperty
Expand Down Expand Up @@ -275,11 +277,14 @@ private fun CoreBigMonospaceText(
), textStyle
)
}
// if the value of `viewState.isLayoutDisabled` is changed, trigger a recomposition
viewState.isLayoutDisabledFlow.collectAsState(initial = false).value
val isLayoutEnabled = !viewState.isLayoutDisabled // not using the value from flow because it is not instantly updated

var width by remember { mutableIntStateOf(0) }
var height by remember { mutableIntStateOf(0) }
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
val contentWidth = debouncedStateOf(200.milliseconds()) {
val (contentWidth, isContentWidthLatest) = debouncedStateOf(200.milliseconds(), tolerateCount = 1, text) { /* handle the first non-zero width instantly */
width - with(density) {
(padding.calculateStartPadding(LayoutDirection.Ltr) + padding.calculateEndPadding(LayoutDirection.Ltr)).toPx()
}
Expand Down Expand Up @@ -335,7 +340,7 @@ private fun CoreBigMonospaceText(
forceRecompose = Random.nextLong()
}

if (contentWidth > 0) {
if (isLayoutEnabled && contentWidth > 0 && isContentWidthLatest) {
remember(transformedText, textLayouter, contentWidth) {
log.d { "CoreBigMonospaceText set contentWidth = $contentWidth" }

Expand Down Expand Up @@ -1099,6 +1104,7 @@ private fun CoreBigMonospaceText(
height = it.size.height
layoutCoordinates = it
viewState.visibleSize = Size(width = width, height = height)
log.v { "BigMonospaceText set width = $width" }
}
.clipToBounds()
.padding(padding)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TransformedText
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow

val EMPTY_SELECTION_RANGE = 0 .. -1

Expand Down Expand Up @@ -158,6 +160,16 @@ class BigTextViewState {
var transformedText: BigTextTransformed? = null
internal set

private val isLayoutDisabledMutableStateFlow = MutableStateFlow(false)

val isLayoutDisabledFlow: Flow<Boolean> = isLayoutDisabledMutableStateFlow

var isLayoutDisabled: Boolean
get() = isLayoutDisabledMutableStateFlow.value
set(value) {
isLayoutDisabledMutableStateFlow.value = value
}

var layoutResult: BigTextSimpleLayoutResult? = null
var visibleSize: Size = Size(0, 0)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ import androidx.compose.ui.text.AnnotatedString
* The argument `initialValue` is only used when there is a cache miss using the cache key `cacheKey`.
*/
@Composable
fun rememberConcurrentLargeAnnotatedBigTextFieldState(initialValue: String = "", vararg cacheKeys: Any?): MutableState<BigTextFieldState> {
fun rememberConcurrentLargeAnnotatedBigTextFieldState(initialValue: String = "", vararg cacheKeys: Any?, initialize: (BigTextFieldState) -> Unit = {}): MutableState<BigTextFieldState> {
return rememberSaveable(*cacheKeys) {
log.i { "cache miss concurrent 1" }
mutableStateOf(
BigTextFieldState(
ConcurrentBigText(BigText.createFromLargeAnnotatedString(AnnotatedString(initialValue))),
BigTextViewState()
)
).apply(initialize)
)
}
}

0 comments on commit 9b20a7c

Please sign in to comment.