Pantrix UI

Tabletop Layout

Bakılan bir içerik ve dokunulan denetimler, bir katlanabilirin nasıl tutulduğuna göre yerleştirilir: bir masada, menteşe yatay ve yarı açıkken içerik kat yerinin üstündeki yarıyı, denetimler ellerin olduğu alttaki yarıyı doldurur; kitap gibi tutulunca ikisi kat yerinin iki yanında durur; katlanmayan bir ekranda denetimler, flatControls'ın dediği gibi içeriğin altında ya da üstündedir. Bir video ve oynatıcısının düğmeleri, görüntülü bir görüşme ve düğmeleri, bir kameranın görüntüsü ve deklanşörü.

import com.pantrixui.components.container.tabletop.PantrixTabletopLayout

Örnekler

Video oynatıcı

Bir video ve oynatıcısı. Düzken düğmeler videonun altında, kendi yüksekliklerinde durur.

Karttaki pencere seçicisinden "Masa"yı seç: video kat yerinin üstüne, düğmeler altına geçer ve alt yarıyı alır; orada arrangement == Tabletop olduğu için düğmeler büyür ve videonun adı da yazılır. Oynat'a bas ve pencereyi değiştir: video kaldığı yerden sürer, iki parça da kompozisyondan çıkmaz.

Masa duruşunda kat yeri 951dp'lik pencerenin ortasındadır: örnek orada pencere kadar uzundur ki kat yeri onu kessin. Bir ekranda düzen zaten Modifier.fillMaxSize() ile bütün pencereyi alır.

var playing by remember { mutableStateOf(false) }
var position by remember { mutableFloatStateOf(VIDEO_START) }
Playing(playing) { position = (position + VIDEO_STEP) % 1f }
PantrixTabletopLayout(
    content = { VideoPane(playing = playing, position = position, tagKey = "player") },
    controls = {
        PlayerControls(
            playing = playing,
            position = position,
            onPlay = { playing = !playing },
            // Masada alt yarının tamamı denetimlerindir: düğmeler büyür, videonun adı da yazılır.
            large = arrangement == TabletopArrangement.Tabletop,
            tagKey = "player"
        )
    },
    modifier = Modifier.fillMaxWidth().height(tabletopExampleHeight()),
    tagKey = "player"
)

@Composable
private fun tabletopExampleHeight(): Dp {
    val window = PantrixTheme.window
    return if (window.posture.isTabletop) window.size.height else TABLETOP_EXAMPLE_HEIGHT
}

@Composable
private fun Playing(
    playing: Boolean,
    step: () -> Unit
) {
    LaunchedEffect(playing) {
        while (playing) {
            delay(VIDEO_TICK_MILLIS)
            step()
        }
    }
}

@Composable
private fun VideoPane(
    playing: Boolean,
    position: Float,
    tagKey: String
) {
    Box(modifier = Modifier.fillMaxSize().background(VIDEO_GROUND), contentAlignment = Alignment.Center) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            if (!playing) PantrixIcon(PantrixIcons.Play, contentDescription = null, size = 48.dp, tint = Color.White)
            PantrixText(text = timeOf(position), color = Color.White, tagKey = "${tagKey}_time")
        }
    }
}

@Composable
private fun PlayerControls(
    playing: Boolean,
    position: Float,
    onPlay: () -> Unit,
    large: Boolean,
    tagKey: String,
    onVideo: Boolean = false
) {
    val spacing = PantrixTheme.spacing
    val ink = if (onVideo) Color.White else PantrixTheme.colors.textPrimary
    Column(
        modifier = Modifier.fillMaxWidth().padding(spacing.md),
        verticalArrangement = Arrangement.spacedBy(spacing.sm, Alignment.CenterVertically)
    ) {
        if (large) PantrixText(text = "Boğaz'da bir gün", style = PantrixTheme.typography.headline, color = ink)
        Progress(position = position, ink = ink)
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(spacing.sm, Alignment.CenterHorizontally),
            verticalAlignment = Alignment.CenterVertically
        ) {
            val size = if (large) ButtonSize.Large else ButtonSize.Medium
            PantrixIconButton(
                imageVector = PantrixIcons.ChevronBack,
                contentDescription = "Geri sar",
                onClick = {},
                size = size,
                tagKey = "${tagKey}_back"
            )
            PantrixIconButton(
                imageVector = if (playing) PantrixIcons.Pause else PantrixIcons.Play,
                contentDescription = if (playing) "Duraklat" else "Oynat",
                onClick = onPlay,
                variant = ButtonVariant.Solid,
                size = size,
                tagKey = "${tagKey}_play"
            )
            PantrixIconButton(
                imageVector = PantrixIcons.ChevronForward,
                contentDescription = "İleri sar",
                onClick = {},
                size = size,
                tagKey = "${tagKey}_forward"
            )
        }
    }
}

private const val VIDEO_START = 0.35f

private const val VIDEO_STEP = 0.004f

@Composable
private fun Progress(
    position: Float,
    ink: Color
) {
    val primary = PantrixTheme.colors.primary.color
    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
        Box(
            modifier = Modifier
                .weight(1f)
                .height(PROGRESS_HEIGHT)
                .drawBehind {
                    val corner = CornerRadius(size.height / 2)
                    drawRoundRect(color = ink.copy(alpha = TRACK_ALPHA), cornerRadius = corner)
                    val played = Size(size.width * position, size.height)
                    drawRoundRect(color = primary, size = played, cornerRadius = corner)
                }
        )
        // Dakikalar soldan sağa yazılır, sağdan sola bir dilde de.
        CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
            PantrixText(
                text = "${timeOf(position)} / ${timeOf(1f)}",
                style = PantrixTheme.typography.footnote,
                color = ink
            )
        }
    }
}

private fun timeOf(position: Float): String {
    val seconds = (position * VIDEO_SECONDS).roundToInt()
    return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}"
}

private val VIDEO_GROUND = Color(0xFF1E2A3A)

private const val VIDEO_TICK_MILLIS = 250L

private val TABLETOP_EXAMPLE_HEIGHT = 420.dp

private val PROGRESS_HEIGHT = 4.dp

private const val TRACK_ALPHA = 0.3f

private const val VIDEO_SECONDS = 240

Denetimler videonun üstünde

Aynı oynatıcı, flatControls = TabletopControls.Overlay ile: düzken video bütün yeri alır, düğmeler onun altına, üstüne biner. Masada yine kat yerinin altına geçerler. Düğmeler videonun üstündeyken (arrangement == Flat) okunabilsinler diye yarı saydam koyu bir zemin çizerler.

var playing by remember { mutableStateOf(false) }
var position by remember { mutableFloatStateOf(VIDEO_START) }
Playing(playing) { position = (position + VIDEO_STEP) % 1f }
PantrixTabletopLayout(
    content = { VideoPane(playing = playing, position = position, tagKey = "overlay") },
    controls = {
        val onVideo = arrangement == TabletopArrangement.Flat
        Box(modifier = if (onVideo) Modifier.background(SCRIM_ON_VIDEO) else Modifier) {
            PlayerControls(
                playing = playing,
                position = position,
                onPlay = { playing = !playing },
                large = arrangement == TabletopArrangement.Tabletop,
                onVideo = onVideo,
                tagKey = "overlay"
            )
        }
    },
    modifier = Modifier.fillMaxWidth().height(tabletopExampleHeight()),
    flatControls = TabletopControls.Overlay,
    tagKey = "overlay"
)

@Composable
private fun tabletopExampleHeight(): Dp {
    val window = PantrixTheme.window
    return if (window.posture.isTabletop) window.size.height else TABLETOP_EXAMPLE_HEIGHT
}

@Composable
private fun Playing(
    playing: Boolean,
    step: () -> Unit
) {
    LaunchedEffect(playing) {
        while (playing) {
            delay(VIDEO_TICK_MILLIS)
            step()
        }
    }
}

@Composable
private fun VideoPane(
    playing: Boolean,
    position: Float,
    tagKey: String
) {
    Box(modifier = Modifier.fillMaxSize().background(VIDEO_GROUND), contentAlignment = Alignment.Center) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            if (!playing) PantrixIcon(PantrixIcons.Play, contentDescription = null, size = 48.dp, tint = Color.White)
            PantrixText(text = timeOf(position), color = Color.White, tagKey = "${tagKey}_time")
        }
    }
}

@Composable
private fun PlayerControls(
    playing: Boolean,
    position: Float,
    onPlay: () -> Unit,
    large: Boolean,
    tagKey: String,
    onVideo: Boolean = false
) {
    val spacing = PantrixTheme.spacing
    val ink = if (onVideo) Color.White else PantrixTheme.colors.textPrimary
    Column(
        modifier = Modifier.fillMaxWidth().padding(spacing.md),
        verticalArrangement = Arrangement.spacedBy(spacing.sm, Alignment.CenterVertically)
    ) {
        if (large) PantrixText(text = "Boğaz'da bir gün", style = PantrixTheme.typography.headline, color = ink)
        Progress(position = position, ink = ink)
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(spacing.sm, Alignment.CenterHorizontally),
            verticalAlignment = Alignment.CenterVertically
        ) {
            val size = if (large) ButtonSize.Large else ButtonSize.Medium
            PantrixIconButton(
                imageVector = PantrixIcons.ChevronBack,
                contentDescription = "Geri sar",
                onClick = {},
                size = size,
                tagKey = "${tagKey}_back"
            )
            PantrixIconButton(
                imageVector = if (playing) PantrixIcons.Pause else PantrixIcons.Play,
                contentDescription = if (playing) "Duraklat" else "Oynat",
                onClick = onPlay,
                variant = ButtonVariant.Solid,
                size = size,
                tagKey = "${tagKey}_play"
            )
            PantrixIconButton(
                imageVector = PantrixIcons.ChevronForward,
                contentDescription = "İleri sar",
                onClick = {},
                size = size,
                tagKey = "${tagKey}_forward"
            )
        }
    }
}

private val SCRIM_ON_VIDEO = Color(0x99000000)

private const val VIDEO_START = 0.35f

private const val VIDEO_STEP = 0.004f

@Composable
private fun Progress(
    position: Float,
    ink: Color
) {
    val primary = PantrixTheme.colors.primary.color
    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
        Box(
            modifier = Modifier
                .weight(1f)
                .height(PROGRESS_HEIGHT)
                .drawBehind {
                    val corner = CornerRadius(size.height / 2)
                    drawRoundRect(color = ink.copy(alpha = TRACK_ALPHA), cornerRadius = corner)
                    val played = Size(size.width * position, size.height)
                    drawRoundRect(color = primary, size = played, cornerRadius = corner)
                }
        )
        // Dakikalar soldan sağa yazılır, sağdan sola bir dilde de.
        CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
            PantrixText(
                text = "${timeOf(position)} / ${timeOf(1f)}",
                style = PantrixTheme.typography.footnote,
                color = ink
            )
        }
    }
}

private fun timeOf(position: Float): String {
    val seconds = (position * VIDEO_SECONDS).roundToInt()
    return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}"
}

private val VIDEO_GROUND = Color(0xFF1E2A3A)

private const val VIDEO_TICK_MILLIS = 250L

private val TABLETOP_EXAMPLE_HEIGHT = 420.dp

private val PROGRESS_HEIGHT = 4.dp

private const val TRACK_ALPHA = 0.3f

private const val VIDEO_SECONDS = 240

Görüntülü görüşme

Görüntülü bir görüşme: kişiler üstte, düğmeler altta. Mikrofon ve kameranın durumu örnekte tutulur; kişilerin kutuları onu okur.

"Masa"da kişiler kat yerinin üstünde kalır, düğmeler altta yan yana büyür: telefon masada dururken eller oradadır.

var muted by remember { mutableStateOf(false) }
var cameraOff by remember { mutableStateOf(false) }
PantrixTabletopLayout(
    content = { CallPeople(muted = muted, cameraOff = cameraOff) },
    controls = {
        val size = if (arrangement == TabletopArrangement.Tabletop) ButtonSize.Large else ButtonSize.Medium
        Row(
            modifier = Modifier.fillMaxWidth().padding(PantrixTheme.spacing.md),
            horizontalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm, Alignment.CenterHorizontally),
            verticalAlignment = Alignment.CenterVertically
        ) {
            PantrixButton(
                text = if (muted) "Sesi aç" else "Sessize al",
                onClick = { muted = !muted },
                variant = ButtonVariant.Outline,
                size = size,
                tagKey = "call_mute"
            )
            PantrixButton(
                text = if (cameraOff) "Kamerayı aç" else "Kamerayı kapat",
                onClick = { cameraOff = !cameraOff },
                variant = ButtonVariant.Outline,
                size = size,
                tagKey = "call_camera"
            )
            PantrixButton(text = "Bitir", onClick = {}, size = size, tagKey = "call_end")
        }
    },
    modifier = Modifier.fillMaxWidth().height(tabletopExampleHeight()),
    tagKey = "call"
)

@Composable
private fun tabletopExampleHeight(): Dp {
    val window = PantrixTheme.window
    return if (window.posture.isTabletop) window.size.height else TABLETOP_EXAMPLE_HEIGHT
}

@Composable
private fun CallPeople(
    muted: Boolean,
    cameraOff: Boolean
) {
    Row(
        modifier = Modifier.fillMaxSize().padding(PantrixTheme.spacing.md),
        horizontalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)
    ) {
        CALL_PEOPLE.forEachIndexed { index, person ->
            val you = index == 0
            PersonTile(
                name = if (you) "Sen" else person.first,
                initials = person.second,
                note = if (you && muted) "Sessizde" else null,
                camera = !(you && cameraOff),
                modifier = Modifier.weight(1f).fillMaxSize()
            )
        }
    }
}

@Composable
private fun PersonTile(
    name: String,
    initials: String,
    note: String?,
    camera: Boolean,
    modifier: Modifier
) {
    PantrixSurface(
        modifier = modifier,
        color = if (camera) VIDEO_GROUND else PantrixTheme.colors.neutral.container
    ) {
        Column(
            modifier = Modifier.align(Alignment.Center),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.xs)
        ) {
            PantrixAvatar(contentDescription = null, initials = initials)
            PantrixText(text = name, color = if (camera) Color.White else Color.Unspecified)
            if (note != null) {
                PantrixText(
                    text = note,
                    style = PantrixTheme.typography.footnote,
                    color = if (camera) Color.White else PantrixTheme.colors.textSecondary
                )
            }
        }
    }
}

private val CALL_PEOPLE = listOf("Sen" to "SN", "Deniz Aksoy" to "DA", "Can Öztürk" to "CÖ")

private val TABLETOP_EXAMPLE_HEIGHT = 420.dp

private val VIDEO_GROUND = Color(0xFF1E2A3A)

Katlanan cihazda: parçalar ölçülerini yazar

Her parça kendi ölçüsünü ve pencere sınıfını yazar. "Kitap"ta 951dp'lik pencere kat yerinde ikiye ayrılır: içerik solda, denetimler sağda. "Masa"da içerik kat yerinin üstünde, denetimler altında; ikisi de 669dp genişliğindedir. Düz bir pencerede denetimler kendi yüksekliğindedir.

// İki parça da bir `TabletopScope` içinde kurulur: `arrangement` düzenin şu anki hâlini söyler.
val controls: @Composable TabletopScope.() -> Unit = {
    MeasuredPart(name = "Denetimler", tall = false, tagKey = "measured_controls")
}
PantrixTabletopLayout(
    content = { MeasuredPart(name = "İçerik", tall = true, tagKey = "measured_content") },
    controls = controls,
    modifier = Modifier.fillMaxWidth().height(tabletopExampleHeight()),
    tagKey = "measured"
)

@Composable
private fun tabletopExampleHeight(): Dp {
    val window = PantrixTheme.window
    return if (window.posture.isTabletop) window.size.height else TABLETOP_EXAMPLE_HEIGHT
}

@Composable
private fun MeasuredPart(
    name: String,
    tall: Boolean,
    tagKey: String
) {
    var size by remember { mutableStateOf(IntSize.Zero) }
    val density = LocalDensity.current
    val words = with(density) { "${size.width.toDp().value.roundToInt()} × ${size.height.toDp().value.roundToInt()}" }
    val room = if (tall) Modifier.fillMaxSize() else Modifier.fillMaxWidth()
    PartSurface(modifier = room.onSizeChanged { size = it }) {
        // Ölçüler soldan sağa yazılır: sağdan sola bir paragrafta genişlik ile yükseklik yer değiştirmiş okunurdu.
        CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
            PantrixText(
                // Parça kendi penceresidir: sınıfı parçanın genişliğine göredir.
                text = "$name: $words dp · ${PantrixTheme.window.widthClass.name}",
                modifier = Modifier.align(Alignment.Center).padding(PantrixTheme.spacing.lg),
                style = PantrixTheme.typography.footnote,
                color = PantrixTheme.colors.textSecondary,
                tagKey = tagKey
            )
        }
    }
}

@Composable
private fun PartSurface(
    modifier: Modifier,
    content: @Composable BoxScope.() -> Unit
) {
    PantrixSurface(modifier = modifier, border = PantrixTheme.colors.border) { content() }
}

private val TABLETOP_EXAMPLE_HEIGHT = 420.dp

API

content

Bakılan bir içerik ve dokunulan denetimler, bir katlanabilirin nasıl tutulduğuna göre yerleştirilir: bir masada, menteşe yatay ve yarı açıkken içerik kat yerinin üstündeki yarıyı, denetimler ellerin olduğu alttaki yarıyı doldurur; kitap gibi tutulunca ikisi kat yerinin iki yanında durur; katlanmayan bir ekranda denetimler, flatControls'ın dediği gibi içeriğin altında ya da üstündedir. Bir video ve oynatıcısının düğmeleri, görüntülü bir görüşme ve düğmeleri, bir kameranın görüntüsü ve deklanşörü.

ParametreTürVarsayılanNe olduğu
content@Composable TabletopScope.() -> Unitbakılan: bir video, bir görüşmenin kişileri, bir kameranın görüntüsü.
controls@Composable TabletopScope.() -> Unitdokunulan: bir oynatıcının düğmeleri ve ilerleyişi, bir görüşmenin düğmeleri, bir deklanşör.
modifierModifierModifieriki parçanın yeri.
flatControlsTabletopControlsTabletopControls.Belowhiçbir kat yeri yeri bölmüyorken denetimlerin nerede olduğu.
tagKeyString?nulldüzenin otomasyon etiketlerinin anahtarı.