https://kotlinlang.org logo
Title
k

Kirill Grouchnikov

11/15/2020, 9:12 PM
Another case is to display a floating "something" (like a tooltip) anchored to the specific composable, but not overflowing the window bounds. Couldn't find anything related to tooltips in compose android / desktop though to see the implementation details.
v

Vadim Kapustin

11/16/2020, 7:43 AM
I'm new to Compose and Kotlin... I tried to make an icon that signals a certain state and a hint that shows the details of the state when hovering over the icon. Here's what happened:
@Composable
fun iconWithHint(
    asset: ImageAsset,
    modifier: Modifier = Modifier,
    tint: Color = AmbientContentColor.current, // for example, if something wrong, we can set color to Color.Red
    popupTimeout: Long = 500,
    popupShowTime: Long = 3000,
    hint: @Composable() () -> Unit // for example { Text(message) }
) {
    val entered = remember { mutableStateOf(0) }
    val popupPosition = remember { mutableStateOf(IntOffset(0, 0)) }
    var enterPosition: IntOffset
    Box(modifier) {
        Icon(asset,
            Modifier.pointerMoveFilter(
                onMove = { position ->
                    popupPosition.value = IntOffset(position.x.toInt(), position.y.toInt())
                    false
                },
                onEnter = {
                    if (entered.value == 0)
                        GlobalScope.launch {
                            entered.value = 1
                            delay(popupTimeout/2)
                            enterPosition = popupPosition.value
                            delay(popupTimeout/2)
                            if (entered.value == 1 && enterPosition == popupPosition.value)
                                entered.value = 2
                        }
                    false
                },
                onExit = {
                    entered.value = 0
                    false
                }
            ),
            tint
        )
        if (entered.value == 2) {
            GlobalScope.launch {
                val count = 10
                for (i in 0.until(count)) {
                    if (entered.value == 0) break
                    delay(popupShowTime/count)
                }
                entered.value = 0
            }
            Popup(
                offset = popupPosition.value
            ) {
                Surface(
                    modifier = Modifier.pointerMoveFilter(
                        onEnter = { false },
                        onExit = {
                            entered.value = 0
                            false
                        }
                    ),
                    shape = RoundedCornerShape(5.dp)
                ) {
                    hint()
                }
            }
        }
    }
}
Any improvements are welcome
👍 1
i

Igor Demin

11/16/2020, 11:40 AM
Here's what happened:
I rewrote this code:
• instead of "GlobalScope.launch" we use LaunchedEffect (LaunchedEffect will launch coroutine in scope of the current Composable function and cancel it when Composable function will be no longer called) • instead of Popup we use hand-maded HintContainer (because Popup will block UI interaction But still hints can't be shown outside the window. Maybe we can achieve this with using AWT directly.
👀 1
import androidx.compose.desktop.Window
import androidx.compose.foundation.clickable
import androidx.compose.material.Text
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offsetPx
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Providers
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.onDispose
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.staticAmbientOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerMoveFilter
import androidx.compose.ui.layout.globalPosition
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.Duration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.inMilliseconds
import androidx.compose.ui.unit.milliseconds
import kotlinx.coroutines.delay

fun main() = Window {
    HintContainer {
        Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
            Text("Left text")

            WithHint(hint = {
                Surface(
                    Modifier.padding(top = 18.dp),
                    color = Color.LightGray,
                ) {
                    Text("Hint", Modifier.padding(4.dp))
                }
            }) {
                Text("Right text", Modifier.clickable {
                    println("Click")
                })
            }
        }
    }
}

val HintsAmbient = staticAmbientOf<SnapshotStateList<@Composable () -> Unit>>()

@Composable
fun HintContainer(children: @Composable () -> Unit) {
    val hints = remember<SnapshotStateList<@Composable () -> Unit>> { mutableStateListOf() }

    Providers(HintsAmbient provides hints) {
        children()
        Box {
            for (hint in hints) {
                hint()
            }
        }
    }
}

@Composable
fun WithHint(
    modifier: Modifier = Modifier,
    delay: Duration = 500.milliseconds,
    hint: @Composable () -> Unit, // for example { Text(message) }
    content: @Composable () -> Unit
) {
    val hints = HintsAmbient.current

    var hoverLocalPosition by remember { mutableStateOf<Offset?>(null) }
    var contentGlobalPosition by remember { mutableStateOf<Offset?>(null) }

    val hintContainer = remember {
        @Composable {
            if (contentGlobalPosition != null && hoverLocalPosition != null) {
                Box(
                    Modifier.offsetPx(
                        derivedStateOf { contentGlobalPosition!!.x + hoverLocalPosition!!.x },
                        derivedStateOf { contentGlobalPosition!!.y + hoverLocalPosition!!.y },
                    )
                ) {
                    hint()
                }
            }
        } as (@Composable () -> Unit)
    }

    if (hoverLocalPosition != null) {
        LaunchedEffect(Unit) {
            delay(delay.inMilliseconds())
            hints.add(hintContainer)
        }
    } else {
        hints.remove(hintContainer)
    }

    onDispose {
        hints.remove(hintContainer)
    }

    Box(
        modifier
            .pointerMoveFilter(
                onMove = {
                    hoverLocalPosition = it
                    false
                },
                onEnter = { false },
                onExit = {
                    hoverLocalPosition = null
                    false
                }
            )
            .onGloballyPositioned {
                contentGlobalPosition = it.globalPosition
            }
    ) {
        content()
    }
}
🙌 2
v

Vadim Kapustin

11/17/2020, 8:27 AM
I implemented your code in my app and added a background for hint:
Surface(shape = RoundedCornerShape(5.dp)) {
        hint()
}
in this case, I sometimes get NullPointerException here:
Modifier.offsetPx(
                        derivedStateOf { contentGlobalPosition!!.x + hoverLocalPosition!!.x },
                        derivedStateOf { contentGlobalPosition!!.y + hoverLocalPosition!!.y },
                    )
I think this is happening when mouse move fast and between checking condition (contentGlobalPosition != null && hoverLocalPosition != null) and drawing hint box, one of the States becomes null. I make small correction:
if (contentGlobalPosition != null && hoverLocalPosition != null) {
                val offset = contentGlobalPosition!! + hoverLocalPosition!!
                Box(
                    Modifier.offsetPx(
                        derivedStateOf { offset.x },
                        derivedStateOf { offset.y },
                    )
                ) {
                    Surface(shape = RoundedCornerShape(5.dp)) {
                            hint()
                    }
                }
            }
I hope It's correct
👍 1
@Igor Demin But overall, your solution is very interesting. I didn't guess that we can combine all the window content in one box with hints :)