Refactor adaptive layout
This commit is contained in:
@@ -15,7 +15,9 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import ch.dissem.yaep.domain.Game
|
||||
import ch.dissem.yaep.ui.common.focus.FocusFollowingFocusable
|
||||
import ch.dissem.yaep.ui.common.focus.SelectionManager
|
||||
import ch.dissem.yaep.ui.common.layout.AdaptiveGameLayout
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
@@ -26,7 +28,7 @@ import kotlin.time.ExperimentalTime
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun App(
|
||||
modifier: Modifier = Modifier,
|
||||
rootSelectionManager: SelectionManager<*>,
|
||||
rootSelectionManager: SelectionManager<FocusFollowingFocusable>,
|
||||
spacing: Dp,
|
||||
game: Game,
|
||||
onNewGame: () -> Unit,
|
||||
@@ -79,7 +81,7 @@ fun App(
|
||||
)
|
||||
}
|
||||
},
|
||||
verticalClues = {selectionManager ->
|
||||
verticalClues = { selectionManager ->
|
||||
if (verticalClues.isNotEmpty()) {
|
||||
for (clue in verticalClues) {
|
||||
VerticalClue(
|
||||
|
||||
@@ -1,480 +0,0 @@
|
||||
package ch.dissem.yaep.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.Placeable
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Constraints.Companion.fixed
|
||||
import androidx.compose.ui.unit.Constraints.Companion.fixedWidth
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ch.dissem.yaep.ui.common.AspectRatio.LANDSCAPE
|
||||
import ch.dissem.yaep.ui.common.AspectRatio.PORTRAIT
|
||||
import ch.dissem.yaep.ui.common.AspectRatio.SQUARISH
|
||||
import ch.dissem.yaep.ui.common.focus.FocusFollowingFocusable
|
||||
import ch.dissem.yaep.ui.common.focus.GridSelectionManager
|
||||
import ch.dissem.yaep.ui.common.focus.LinearSelectionManager
|
||||
import ch.dissem.yaep.ui.common.focus.SelectionManager
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
private enum class AspectRatio {
|
||||
PORTRAIT, LANDSCAPE, SQUARISH;
|
||||
|
||||
companion object {
|
||||
private const val ASPECT_RATIO_LANDSCAPE = 1.4f
|
||||
private const val ASPECT_RATIO_PORTRAIT = 1 / ASPECT_RATIO_LANDSCAPE
|
||||
|
||||
fun from(constraints: Constraints): AspectRatio {
|
||||
val ratio = constraints.maxWidth.toFloat() / constraints.maxHeight.toFloat()
|
||||
return when {
|
||||
ratio < ASPECT_RATIO_PORTRAIT -> PORTRAIT
|
||||
ratio > ASPECT_RATIO_LANDSCAPE -> LANDSCAPE
|
||||
else -> SQUARISH
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AdaptiveGameLayout(
|
||||
modifier: Modifier = Modifier,
|
||||
selectionManager: SelectionManager<*>,
|
||||
grid: @Composable (GridSelectionManager) -> Unit,
|
||||
horizontalClues: @Composable (SelectionManager<*>) -> Unit,
|
||||
verticalClues: @Composable (SelectionManager<*>) -> Unit,
|
||||
time: @Composable () -> Unit,
|
||||
divider: @Composable () -> Unit = { HorizontalDivider() },
|
||||
spacing: Dp = 8.dp
|
||||
) {
|
||||
val gridFocusable = selectionManager.add()
|
||||
val gridSelectionManager = gridFocusable.create(GridSelectionManager())
|
||||
|
||||
val horizontalCluesFocusable = selectionManager.add()
|
||||
val horizontalCluesSelectionManager = horizontalCluesFocusable.create(
|
||||
LinearSelectionManager(Key.DirectionRight, Key.DirectionLeft)
|
||||
)
|
||||
|
||||
val verticalCluesFocusable = selectionManager.add()
|
||||
val verticalCluesSelectionManager = verticalCluesFocusable.create(
|
||||
LinearSelectionManager(Key.DirectionRight, Key.DirectionLeft)
|
||||
)
|
||||
Layout(
|
||||
contents = listOf(
|
||||
{
|
||||
Box(modifier = Modifier.focus(gridFocusable))
|
||||
LaunchedEffect(Unit) {
|
||||
if (gridFocusable is FocusFollowingFocusable) {
|
||||
gridFocusable.focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ Box(modifier = Modifier.focus(horizontalCluesFocusable)) },
|
||||
{ Box(modifier = Modifier.focus(verticalCluesFocusable)) },
|
||||
{ grid(gridSelectionManager) },
|
||||
{ horizontalClues(horizontalCluesSelectionManager) },
|
||||
{ verticalClues(verticalCluesSelectionManager) },
|
||||
time,
|
||||
divider,
|
||||
divider
|
||||
),
|
||||
modifier = modifier
|
||||
) { measurables, constraints ->
|
||||
layout(width = constraints.maxWidth, height = constraints.maxHeight) {
|
||||
val aspectRatio = AspectRatio.from(constraints)
|
||||
|
||||
val gridBoxMeasurable = measurables[0][0]
|
||||
val horizontalCluesBoxMeasurable = measurables[1][0]
|
||||
val verticalCluesBoxMeasurable = measurables[2][0]
|
||||
|
||||
val gridMeasurable = measurables[3][0]
|
||||
val horizontalCluesMeasurables = measurables[4]
|
||||
val verticalCluesMeasurables = measurables[5]
|
||||
val timeMeasurable = measurables[6][0]
|
||||
val dividerMeasurable = measurables[7][0]
|
||||
|
||||
val spacingPx = spacing.roundToPx()
|
||||
|
||||
when (aspectRatio) {
|
||||
PORTRAIT -> {
|
||||
val divider2Measurable = measurables[8][0]
|
||||
portrait(
|
||||
constraints,
|
||||
spacingPx,
|
||||
gridMeasurable,
|
||||
gridBoxMeasurable,
|
||||
horizontalCluesMeasurables,
|
||||
horizontalCluesBoxMeasurable,
|
||||
verticalCluesMeasurables,
|
||||
verticalCluesBoxMeasurable,
|
||||
timeMeasurable,
|
||||
dividerMeasurable,
|
||||
divider2Measurable
|
||||
)
|
||||
}
|
||||
|
||||
SQUARISH -> {
|
||||
squarish(
|
||||
constraints,
|
||||
spacingPx,
|
||||
gridMeasurable,
|
||||
gridBoxMeasurable,
|
||||
horizontalCluesMeasurables,
|
||||
horizontalCluesBoxMeasurable,
|
||||
verticalCluesMeasurables,
|
||||
verticalCluesBoxMeasurable,
|
||||
timeMeasurable
|
||||
)
|
||||
}
|
||||
|
||||
LANDSCAPE -> {
|
||||
landscape(
|
||||
constraints,
|
||||
spacingPx,
|
||||
gridMeasurable,
|
||||
gridBoxMeasurable,
|
||||
horizontalCluesMeasurables,
|
||||
horizontalCluesBoxMeasurable,
|
||||
verticalCluesMeasurables,
|
||||
verticalCluesBoxMeasurable,
|
||||
timeMeasurable,
|
||||
dividerMeasurable
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Placeable.PlacementScope.portrait(
|
||||
constraints: Constraints,
|
||||
spacingPx: Int,
|
||||
gridMeasurable: Measurable,
|
||||
gridBoxMeasurable: Measurable,
|
||||
horizontalCluesMeasurables: List<Measurable>,
|
||||
horizontalCluesBoxMeasurable: Measurable,
|
||||
verticalCluesMeasurables: List<Measurable>,
|
||||
verticalCluesBoxMeasurable: Measurable,
|
||||
timeMeasurable: Measurable,
|
||||
divider1Measurable: Measurable,
|
||||
divider2Measurable: Measurable
|
||||
) {
|
||||
val gridSize = constraints.maxWidth
|
||||
val gridItemSize = (gridSize - 12 * spacingPx) / 18
|
||||
|
||||
val gridConstraints = fixed(gridSize, gridSize)
|
||||
val horizontalCluesConstraints = fixed(
|
||||
width = 3 * gridItemSize + 2 * spacingPx,
|
||||
height = gridItemSize + 2 * spacingPx
|
||||
)
|
||||
val verticalCluesConstraints = fixed(
|
||||
width = gridItemSize + 2 * spacingPx,
|
||||
height = 3 * gridItemSize + 2 * spacingPx
|
||||
)
|
||||
val timeConstraints = Constraints()
|
||||
val dividerConstraints = fixedWidth(gridSize)
|
||||
|
||||
val gridPlaceable = gridMeasurable.measure(gridConstraints)
|
||||
val gridBoxPlaceable = gridBoxMeasurable.measure(gridConstraints)
|
||||
val horizontalCluesPlaceables = horizontalCluesMeasurables.map {
|
||||
it.measure(horizontalCluesConstraints)
|
||||
}
|
||||
val verticalCluesPlaceables = verticalCluesMeasurables.map {
|
||||
it.measure(verticalCluesConstraints)
|
||||
}
|
||||
val timePlaceable = timeMeasurable.measure(timeConstraints)
|
||||
val divider1Placeable = divider1Measurable.measure(dividerConstraints)
|
||||
val divider2Placeable = divider2Measurable.measure(dividerConstraints)
|
||||
|
||||
val horizontalCluesBoxPlaceable = horizontalCluesBoxMeasurable
|
||||
.measure(
|
||||
cluesBoxConstraints(
|
||||
width = gridSize,
|
||||
itemConstraints = horizontalCluesConstraints,
|
||||
itemCount = horizontalCluesMeasurables.size
|
||||
)
|
||||
)
|
||||
val verticalCluesBoxPlaceable = verticalCluesBoxMeasurable
|
||||
.measure(
|
||||
cluesBoxConstraints(
|
||||
width = gridSize,
|
||||
itemConstraints = verticalCluesConstraints,
|
||||
itemCount = verticalCluesMeasurables.size
|
||||
)
|
||||
)
|
||||
|
||||
// Position the grid
|
||||
gridPlaceable.place(0, 0)
|
||||
gridBoxPlaceable.place(0, 0)
|
||||
|
||||
divider1Placeable.place(0, gridSize + spacingPx)
|
||||
|
||||
// Position the horizontal clues
|
||||
val horizontalCluesOffsetY = gridSize + 2 * spacingPx
|
||||
|
||||
var offsetY = placeClues(
|
||||
placeables = horizontalCluesPlaceables,
|
||||
offsetX = 0,
|
||||
offsetY = horizontalCluesOffsetY,
|
||||
maxWidth = gridSize
|
||||
)
|
||||
horizontalCluesBoxPlaceable.place(0, horizontalCluesOffsetY)
|
||||
verticalCluesBoxPlaceable.place(0, horizontalCluesOffsetY)
|
||||
|
||||
// Add divider in between
|
||||
divider2Placeable.place(0, offsetY + spacingPx)
|
||||
|
||||
// Position the vertical clues
|
||||
val verticalCluesOffsetY = offsetY + spacingPx + divider2Placeable.height
|
||||
offsetY = placeClues(
|
||||
placeables = verticalCluesPlaceables,
|
||||
offsetX = 0,
|
||||
offsetY = verticalCluesOffsetY,
|
||||
maxWidth = gridSize
|
||||
)
|
||||
verticalCluesBoxPlaceable.place(0, verticalCluesOffsetY)
|
||||
|
||||
// Position the time
|
||||
val remainingSpace = constraints.maxHeight - offsetY
|
||||
if (remainingSpace < timePlaceable.height) {
|
||||
val scale = remainingSpace.toFloat() / timePlaceable.height.toFloat()
|
||||
if (scale > 0.1f) {
|
||||
timePlaceable.placeWithLayer(
|
||||
x = constraints.maxWidth - timePlaceable.width - spacingPx,
|
||||
y = constraints.maxHeight - timePlaceable.height
|
||||
) {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = (timePlaceable.width * (1 - scale)) / 2f
|
||||
translationY = (timePlaceable.height * (1 - scale)) / 2f
|
||||
}
|
||||
}
|
||||
} else {
|
||||
timePlaceable.place(
|
||||
x = constraints.maxWidth - timePlaceable.width - spacingPx,
|
||||
y = constraints.maxHeight - timePlaceable.height
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Placeable.PlacementScope.squarish(
|
||||
constraints: Constraints,
|
||||
spacingPx: Int,
|
||||
gridMeasurable: Measurable,
|
||||
gridBoxMeasurable: Measurable,
|
||||
horizontalCluesMeasurables: List<Measurable>,
|
||||
horizontalCluesBoxMeasurable: Measurable,
|
||||
verticalCluesMeasurables: List<Measurable>,
|
||||
verticalCluesBoxMeasurable: Measurable,
|
||||
timeMeasurable: Measurable
|
||||
) {
|
||||
val gridSize = (7 * min(constraints.maxWidth, constraints.maxHeight)) / 10
|
||||
val gridItemSize = (gridSize - 12 * spacingPx) / 18
|
||||
val rightBarWidth = constraints.maxWidth - gridSize - spacingPx
|
||||
|
||||
val gridConstraints = fixed(gridSize, gridSize)
|
||||
val horizontalCluesConstraints = fixed(
|
||||
width = 3 * gridItemSize + 2 * spacingPx,
|
||||
height = gridItemSize + 2 * spacingPx
|
||||
)
|
||||
val verticalCluesConstraints = fixed(
|
||||
width = gridItemSize + 2 * spacingPx,
|
||||
height = 3 * gridItemSize + 2 * spacingPx
|
||||
)
|
||||
val timeConstraints = Constraints()
|
||||
|
||||
val gridPlaceable = gridMeasurable.measure(gridConstraints)
|
||||
val gridBoxPlaceable = gridBoxMeasurable.measure(gridConstraints)
|
||||
val horizontalCluesPlaceables = horizontalCluesMeasurables.map {
|
||||
it.measure(horizontalCluesConstraints)
|
||||
}
|
||||
val horizontalCluesBoxPlaceable = horizontalCluesBoxMeasurable
|
||||
.measure(
|
||||
cluesBoxConstraints(
|
||||
width = rightBarWidth,
|
||||
itemConstraints = horizontalCluesConstraints,
|
||||
itemCount = horizontalCluesMeasurables.size
|
||||
)
|
||||
)
|
||||
|
||||
val verticalCluesPlaceables = verticalCluesMeasurables.map {
|
||||
it.measure(verticalCluesConstraints)
|
||||
}
|
||||
val verticalCluesBoxPlaceable = verticalCluesBoxMeasurable
|
||||
.measure(
|
||||
cluesBoxConstraints(
|
||||
width = rightBarWidth,
|
||||
itemConstraints = verticalCluesConstraints,
|
||||
itemCount = verticalCluesMeasurables.size
|
||||
)
|
||||
)
|
||||
val timePlaceable = timeMeasurable.measure(timeConstraints)
|
||||
|
||||
// Position the grid
|
||||
gridPlaceable.place(0, 0)
|
||||
gridBoxPlaceable.place(0, 0)
|
||||
|
||||
// Position the horizontal clues
|
||||
val horizontalCluesOffsetX = gridSize + 2 * spacingPx
|
||||
placeClues(
|
||||
placeables = horizontalCluesPlaceables,
|
||||
offsetX = horizontalCluesOffsetX,
|
||||
offsetY = 0,
|
||||
maxWidth = rightBarWidth
|
||||
)
|
||||
horizontalCluesBoxPlaceable.place(horizontalCluesOffsetX, 0)
|
||||
|
||||
// Position the vertical clues
|
||||
val verticalCluesOffsetY = gridSize + 2 * spacingPx
|
||||
placeClues(
|
||||
placeables = verticalCluesPlaceables,
|
||||
offsetX = 0,
|
||||
offsetY = verticalCluesOffsetY,
|
||||
maxWidth = gridSize
|
||||
)
|
||||
verticalCluesBoxPlaceable.place(0, verticalCluesOffsetY)
|
||||
|
||||
// Position the time
|
||||
timePlaceable.place(
|
||||
x = constraints.maxWidth - timePlaceable.width - spacingPx,
|
||||
y = constraints.maxHeight - timePlaceable.height
|
||||
)
|
||||
}
|
||||
|
||||
private fun Placeable.PlacementScope.landscape(
|
||||
constraints: Constraints,
|
||||
spacingPx: Int,
|
||||
gridMeasurable: Measurable,
|
||||
gridBoxMeasurable: Measurable,
|
||||
horizontalCluesMeasurables: List<Measurable>,
|
||||
horizontalCluesBoxMeasurable: Measurable,
|
||||
verticalCluesMeasurables: List<Measurable>,
|
||||
verticalCluesBoxMeasurable: Measurable,
|
||||
timeMeasurable: Measurable,
|
||||
dividerMeasurable: Measurable
|
||||
) {
|
||||
val gridSize = constraints.maxHeight
|
||||
val gridItemSize = (gridSize - 12 * spacingPx) / 18
|
||||
val rightBarWidth = constraints.maxWidth - gridSize - 2 * spacingPx
|
||||
|
||||
val gridConstraints = fixed(gridSize, gridSize)
|
||||
val baseSpace = gridSize - 2 * spacingPx
|
||||
val horizontalCluesConstraints = fixed(
|
||||
width = 3 * gridItemSize + 2 * spacingPx,
|
||||
height = gridItemSize + 2 * spacingPx
|
||||
)
|
||||
val verticalCluesConstraints = fixed(
|
||||
width = gridItemSize + 2 * spacingPx,
|
||||
height = 3 * gridItemSize + 2 * spacingPx
|
||||
)
|
||||
val timeConstraints = Constraints.fixedHeight(baseSpace / 10)
|
||||
val dividerConstraints = fixedWidth(rightBarWidth - 2 * spacingPx)
|
||||
|
||||
val gridPlaceable = gridMeasurable.measure(gridConstraints)
|
||||
val gridBoxPlaceable = gridBoxMeasurable.measure(gridConstraints)
|
||||
val horizontalCluesPlaceables = horizontalCluesMeasurables.map {
|
||||
it.measure(horizontalCluesConstraints)
|
||||
}
|
||||
val horizontalCluesBoxPlaceable = horizontalCluesBoxMeasurable
|
||||
.measure(
|
||||
cluesBoxConstraints(
|
||||
width = rightBarWidth,
|
||||
itemConstraints = horizontalCluesConstraints,
|
||||
itemCount = horizontalCluesMeasurables.size
|
||||
)
|
||||
)
|
||||
|
||||
val verticalCluesPlaceables = verticalCluesMeasurables.map {
|
||||
it.measure(verticalCluesConstraints)
|
||||
}
|
||||
val verticalCluesBoxPlaceable = verticalCluesBoxMeasurable
|
||||
.measure(
|
||||
cluesBoxConstraints(
|
||||
width = rightBarWidth,
|
||||
itemConstraints = verticalCluesConstraints,
|
||||
itemCount = verticalCluesMeasurables.size
|
||||
)
|
||||
)
|
||||
|
||||
val timePlaceable = timeMeasurable.measure(timeConstraints)
|
||||
val dividerPlaceable = dividerMeasurable.measure(dividerConstraints)
|
||||
|
||||
// Position the grid
|
||||
gridPlaceable.place(0, 0)
|
||||
gridBoxPlaceable.place(0, 0)
|
||||
|
||||
// Position the horizontal clues4
|
||||
val horizontalCluesOffsetX = gridSize + 2 * spacingPx
|
||||
val offsetY = placeClues(
|
||||
placeables = horizontalCluesPlaceables,
|
||||
offsetX = horizontalCluesOffsetX,
|
||||
offsetY = 0,
|
||||
maxWidth = rightBarWidth
|
||||
)
|
||||
horizontalCluesBoxPlaceable.place(horizontalCluesOffsetX, 0)
|
||||
|
||||
// Add divider in between
|
||||
dividerPlaceable.place(gridSize + 3 * spacingPx, offsetY + spacingPx)
|
||||
|
||||
// Position the vertical clues
|
||||
val verticalCluesOffsetX = gridSize + 2 * spacingPx
|
||||
val verticalCluesOffsetY = offsetY + spacingPx + dividerPlaceable.height
|
||||
placeClues(
|
||||
placeables = verticalCluesPlaceables,
|
||||
offsetX = verticalCluesOffsetX,
|
||||
offsetY = verticalCluesOffsetY,
|
||||
maxWidth = rightBarWidth
|
||||
)
|
||||
verticalCluesBoxPlaceable.place(verticalCluesOffsetX, verticalCluesOffsetY)
|
||||
|
||||
// Position the time
|
||||
timePlaceable.place(
|
||||
x = constraints.maxWidth - timePlaceable.width - spacingPx,
|
||||
y = constraints.maxHeight - timePlaceable.height
|
||||
)
|
||||
}
|
||||
|
||||
private fun cluesBoxConstraints(
|
||||
width: Int,
|
||||
itemConstraints: Constraints,
|
||||
itemCount: Int
|
||||
): Constraints = fixed(
|
||||
width,
|
||||
itemConstraints.maxHeight * ceil(itemCount.toFloat() / (width / itemConstraints.maxWidth)).toInt()
|
||||
)
|
||||
|
||||
private fun Placeable.PlacementScope.placeClues(
|
||||
placeables: List<Placeable>,
|
||||
offsetX: Int,
|
||||
offsetY: Int,
|
||||
maxWidth: Int
|
||||
): Int {
|
||||
if (placeables.isEmpty()) return offsetY
|
||||
|
||||
val itemWidth = placeables.first().width
|
||||
val itemHeight = placeables.first().height
|
||||
val columns = max(1, maxWidth / itemWidth)
|
||||
val spacing = if (columns == 1) 0 else (maxWidth - columns * itemWidth) / (columns - 1)
|
||||
var currentX = offsetX
|
||||
var currentY = offsetY
|
||||
var i = 0
|
||||
for (placeable in placeables) {
|
||||
placeable.place(currentX, currentY)
|
||||
currentX += itemWidth + spacing
|
||||
i++
|
||||
if (i % columns == 0 && i < placeables.size) {
|
||||
currentX = offsetX
|
||||
currentY += itemHeight
|
||||
}
|
||||
}
|
||||
return currentY + itemHeight
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package ch.dissem.yaep.ui.common.layout
|
||||
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
|
||||
enum class AspectRatio {
|
||||
PORTRAIT, LANDSCAPE, SQUARISH;
|
||||
|
||||
companion object {
|
||||
private const val ASPECT_RATIO_LANDSCAPE = 1.4f
|
||||
private const val ASPECT_RATIO_PORTRAIT = 1 / ASPECT_RATIO_LANDSCAPE
|
||||
|
||||
fun from(constraints: Constraints): AspectRatio {
|
||||
val ratio = constraints.maxWidth.toFloat() / constraints.maxHeight.toFloat()
|
||||
return when {
|
||||
ratio < ASPECT_RATIO_PORTRAIT -> PORTRAIT
|
||||
ratio > ASPECT_RATIO_LANDSCAPE -> LANDSCAPE
|
||||
else -> SQUARISH
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package ch.dissem.yaep.ui.common.layout
|
||||
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
|
||||
data class FocusGroup(
|
||||
val items: List<Measurable>,
|
||||
val box: Measurable
|
||||
) {
|
||||
val item: Measurable
|
||||
get() = items.single()
|
||||
|
||||
val count = items.size
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package ch.dissem.yaep.ui.common.layout
|
||||
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.Placeable
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Constraints.Companion.fixed
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ch.dissem.yaep.ui.common.focus.FocusFollowingFocusable
|
||||
import ch.dissem.yaep.ui.common.focus.GridSelectionManager
|
||||
import ch.dissem.yaep.ui.common.focus.LinearSelectionManager
|
||||
import ch.dissem.yaep.ui.common.focus.SelectionManager
|
||||
import ch.dissem.yaep.ui.common.layout.AspectRatio.LANDSCAPE
|
||||
import ch.dissem.yaep.ui.common.layout.AspectRatio.PORTRAIT
|
||||
import ch.dissem.yaep.ui.common.layout.AspectRatio.SQUARISH
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.max
|
||||
|
||||
|
||||
@Composable
|
||||
fun AdaptiveGameLayout(
|
||||
modifier: Modifier = Modifier,
|
||||
selectionManager: SelectionManager<FocusFollowingFocusable>,
|
||||
grid: @Composable (GridSelectionManager) -> Unit,
|
||||
horizontalClues: @Composable (SelectionManager<*>) -> Unit,
|
||||
verticalClues: @Composable (SelectionManager<*>) -> Unit,
|
||||
time: @Composable () -> Unit,
|
||||
spacing: Dp = 8.dp
|
||||
) {
|
||||
val gridFocusable = selectionManager.add()
|
||||
val gridSelectionManager = gridFocusable.create(GridSelectionManager())
|
||||
|
||||
val horizontalCluesFocusable = selectionManager.add()
|
||||
val horizontalCluesSelectionManager = horizontalCluesFocusable.create(
|
||||
LinearSelectionManager(Key.DirectionRight, Key.DirectionLeft)
|
||||
)
|
||||
|
||||
val verticalCluesFocusable = selectionManager.add()
|
||||
val verticalCluesSelectionManager = verticalCluesFocusable.create(
|
||||
LinearSelectionManager(Key.DirectionRight, Key.DirectionLeft)
|
||||
)
|
||||
Layout(
|
||||
contents = listOf(
|
||||
{ grid(gridSelectionManager) },
|
||||
{ FocusBox(gridFocusable, requestFocus = true) },
|
||||
{ horizontalClues(horizontalCluesSelectionManager) },
|
||||
{ FocusBox(horizontalCluesFocusable) },
|
||||
{ verticalClues(verticalCluesSelectionManager) },
|
||||
{ FocusBox(verticalCluesFocusable) },
|
||||
time,
|
||||
{
|
||||
HorizontalDivider()
|
||||
HorizontalDivider()
|
||||
}
|
||||
),
|
||||
modifier = modifier
|
||||
) { measurables, constraints ->
|
||||
layout(width = constraints.maxWidth, height = constraints.maxHeight) {
|
||||
val aspectRatio = AspectRatio.from(constraints)
|
||||
|
||||
val gridGroup = FocusGroup(
|
||||
measurables[0],
|
||||
measurables[1][0],
|
||||
)
|
||||
val horizontalCluesGroup = FocusGroup(
|
||||
measurables[2],
|
||||
measurables[3][0]
|
||||
)
|
||||
val verticalCluesGroup = FocusGroup(
|
||||
measurables[4],
|
||||
measurables[5][0]
|
||||
)
|
||||
val timeMeasurable = measurables[6][0]
|
||||
val dividerMeasurables = measurables[7]
|
||||
|
||||
val spacingPx = spacing.roundToPx()
|
||||
|
||||
when (aspectRatio) {
|
||||
PORTRAIT -> {
|
||||
portrait(
|
||||
constraints,
|
||||
spacingPx,
|
||||
gridGroup,
|
||||
horizontalCluesGroup,
|
||||
verticalCluesGroup,
|
||||
timeMeasurable,
|
||||
dividerMeasurables
|
||||
)
|
||||
}
|
||||
|
||||
SQUARISH -> {
|
||||
squarish(
|
||||
constraints,
|
||||
spacingPx,
|
||||
gridGroup,
|
||||
horizontalCluesGroup,
|
||||
verticalCluesGroup,
|
||||
timeMeasurable
|
||||
)
|
||||
}
|
||||
|
||||
LANDSCAPE -> {
|
||||
landscape(
|
||||
constraints,
|
||||
spacingPx,
|
||||
gridGroup,
|
||||
horizontalCluesGroup,
|
||||
verticalCluesGroup,
|
||||
timeMeasurable,
|
||||
dividerMeasurables[0]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun cluesBoxConstraints(
|
||||
width: Int,
|
||||
itemConstraints: Constraints,
|
||||
itemCount: Int
|
||||
): Constraints = fixed(
|
||||
width,
|
||||
itemConstraints.maxHeight * ceil(itemCount.toFloat() / (width / itemConstraints.maxWidth)).toInt()
|
||||
)
|
||||
|
||||
internal fun Placeable.PlacementScope.placeClues(
|
||||
placeables: List<Placeable>,
|
||||
offsetX: Int,
|
||||
offsetY: Int,
|
||||
maxWidth: Int
|
||||
): Int {
|
||||
if (placeables.isEmpty()) return offsetY
|
||||
|
||||
val itemWidth = placeables.first().width
|
||||
val itemHeight = placeables.first().height
|
||||
val columns = max(1, maxWidth / itemWidth)
|
||||
val spacing = if (columns == 1) 0 else (maxWidth - columns * itemWidth) / (columns - 1)
|
||||
var currentX = offsetX
|
||||
var currentY = offsetY
|
||||
var i = 0
|
||||
for (placeable in placeables) {
|
||||
placeable.place(currentX, currentY)
|
||||
currentX += itemWidth + spacing
|
||||
i++
|
||||
if (i % columns == 0 && i < placeables.size) {
|
||||
currentX = offsetX
|
||||
currentY += itemHeight
|
||||
}
|
||||
}
|
||||
return currentY + itemHeight
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package ch.dissem.yaep.ui.common.layout
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.focus.onFocusEvent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import ch.dissem.yaep.ui.common.focus.FocusFollowingFocusable
|
||||
|
||||
@Composable
|
||||
fun FocusBox(focusable: FocusFollowingFocusable, requestFocus: Boolean = false) {
|
||||
val hasFocus by focusable.hasFocus.collectAsState(false)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.focusRequester(focusable.focusRequester)
|
||||
.onFocusEvent { state ->
|
||||
focusable.setFocus(state)
|
||||
}
|
||||
.onFocusChanged {}
|
||||
.focusable()
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(
|
||||
if (hasFocus) {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
),
|
||||
)
|
||||
if (requestFocus) {
|
||||
LaunchedEffect(Unit) {
|
||||
focusable.focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package ch.dissem.yaep.ui.common.layout
|
||||
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.Placeable
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Constraints.Companion.fixed
|
||||
import androidx.compose.ui.unit.Constraints.Companion.fixedWidth
|
||||
|
||||
internal fun Placeable.PlacementScope.landscape(
|
||||
constraints: Constraints,
|
||||
spacingPx: Int,
|
||||
grid: FocusGroup,
|
||||
horizontalClues: FocusGroup,
|
||||
verticalClues: FocusGroup,
|
||||
timeMeasurable: Measurable,
|
||||
dividerMeasurable: Measurable
|
||||
) {
|
||||
val gridSize = constraints.maxHeight
|
||||
val gridItemSize = (gridSize - 12 * spacingPx) / 18
|
||||
val rightBarWidth = constraints.maxWidth - gridSize - 2 * spacingPx
|
||||
|
||||
val gridConstraints = fixed(gridSize, gridSize)
|
||||
val baseSpace = gridSize - 2 * spacingPx
|
||||
val horizontalCluesConstraints = fixed(
|
||||
width = 3 * gridItemSize + 2 * spacingPx,
|
||||
height = gridItemSize + 2 * spacingPx
|
||||
)
|
||||
val verticalCluesConstraints = fixed(
|
||||
width = gridItemSize + 2 * spacingPx,
|
||||
height = 3 * gridItemSize + 2 * spacingPx
|
||||
)
|
||||
val timeConstraints = Constraints.fixedHeight(baseSpace / 10)
|
||||
val dividerConstraints = fixedWidth(rightBarWidth - 2 * spacingPx)
|
||||
|
||||
val gridPlaceable = grid.item.measure(gridConstraints)
|
||||
val gridBoxPlaceable = grid.box.measure(gridConstraints)
|
||||
val horizontalCluesPlaceables = horizontalClues.items.map {
|
||||
it.measure(horizontalCluesConstraints)
|
||||
}
|
||||
val horizontalCluesBoxPlaceable = horizontalClues.box
|
||||
.measure(
|
||||
cluesBoxConstraints(
|
||||
width = rightBarWidth,
|
||||
itemConstraints = horizontalCluesConstraints,
|
||||
itemCount = horizontalClues.count
|
||||
)
|
||||
)
|
||||
|
||||
val verticalCluesPlaceables = verticalClues.items.map {
|
||||
it.measure(verticalCluesConstraints)
|
||||
}
|
||||
val verticalCluesBoxPlaceable = verticalClues.box
|
||||
.measure(
|
||||
cluesBoxConstraints(
|
||||
width = rightBarWidth,
|
||||
itemConstraints = verticalCluesConstraints,
|
||||
itemCount = verticalClues.count
|
||||
)
|
||||
)
|
||||
|
||||
val timePlaceable = timeMeasurable.measure(timeConstraints)
|
||||
val dividerPlaceable = dividerMeasurable.measure(dividerConstraints)
|
||||
|
||||
// Position the grid
|
||||
gridBoxPlaceable.place(0, 0)
|
||||
gridPlaceable.place(0, 0)
|
||||
|
||||
// Position the horizontal clues4
|
||||
val horizontalCluesOffsetX = gridSize + 2 * spacingPx
|
||||
horizontalCluesBoxPlaceable.place(horizontalCluesOffsetX, 0)
|
||||
val offsetY = placeClues(
|
||||
placeables = horizontalCluesPlaceables,
|
||||
offsetX = horizontalCluesOffsetX,
|
||||
offsetY = 0,
|
||||
maxWidth = rightBarWidth
|
||||
)
|
||||
|
||||
// Add divider in between
|
||||
dividerPlaceable.place(gridSize + 3 * spacingPx, offsetY + spacingPx)
|
||||
|
||||
// Position the vertical clues
|
||||
val verticalCluesOffsetX = gridSize + 2 * spacingPx
|
||||
val verticalCluesOffsetY = offsetY + spacingPx + dividerPlaceable.height
|
||||
verticalCluesBoxPlaceable.place(verticalCluesOffsetX, verticalCluesOffsetY)
|
||||
placeClues(
|
||||
placeables = verticalCluesPlaceables,
|
||||
offsetX = verticalCluesOffsetX,
|
||||
offsetY = verticalCluesOffsetY,
|
||||
maxWidth = rightBarWidth
|
||||
)
|
||||
|
||||
// Position the time
|
||||
timePlaceable.place(
|
||||
x = constraints.maxWidth - timePlaceable.width - spacingPx,
|
||||
y = constraints.maxHeight - timePlaceable.height
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package ch.dissem.yaep.ui.common.layout
|
||||
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.Placeable
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Constraints.Companion.fixed
|
||||
import androidx.compose.ui.unit.Constraints.Companion.fixedWidth
|
||||
|
||||
internal fun Placeable.PlacementScope.portrait(
|
||||
constraints: Constraints,
|
||||
spacingPx: Int,
|
||||
grid: FocusGroup,
|
||||
horizontalClues: FocusGroup,
|
||||
verticalClues: FocusGroup,
|
||||
timeMeasurable: Measurable,
|
||||
dividerMeasurables: List<Measurable>
|
||||
) {
|
||||
val gridSize = constraints.maxWidth
|
||||
val gridItemSize = (gridSize - 12 * spacingPx) / 18
|
||||
|
||||
val gridConstraints = fixed(gridSize, gridSize)
|
||||
val horizontalCluesConstraints = fixed(
|
||||
width = 3 * gridItemSize + 2 * spacingPx,
|
||||
height = gridItemSize + 2 * spacingPx
|
||||
)
|
||||
val verticalCluesConstraints = fixed(
|
||||
width = gridItemSize + 2 * spacingPx,
|
||||
height = 3 * gridItemSize + 2 * spacingPx
|
||||
)
|
||||
val timeConstraints = Constraints()
|
||||
val dividerConstraints = fixedWidth(gridSize)
|
||||
|
||||
val gridPlaceable = grid.item.measure(gridConstraints)
|
||||
val gridBoxPlaceable = grid.box.measure(gridConstraints)
|
||||
val horizontalCluesPlaceables = horizontalClues.items.map {
|
||||
it.measure(horizontalCluesConstraints)
|
||||
}
|
||||
val verticalCluesPlaceables = verticalClues.items.map {
|
||||
it.measure(verticalCluesConstraints)
|
||||
}
|
||||
val timePlaceable = timeMeasurable.measure(timeConstraints)
|
||||
val divider1Placeable = dividerMeasurables[0].measure(dividerConstraints)
|
||||
val divider2Placeable = dividerMeasurables[1].measure(dividerConstraints)
|
||||
|
||||
val horizontalCluesBoxPlaceable = horizontalClues.box
|
||||
.measure(
|
||||
cluesBoxConstraints(
|
||||
width = gridSize,
|
||||
itemConstraints = horizontalCluesConstraints,
|
||||
itemCount = horizontalClues.count
|
||||
)
|
||||
)
|
||||
val verticalCluesBoxPlaceable = verticalClues.box
|
||||
.measure(
|
||||
cluesBoxConstraints(
|
||||
width = gridSize,
|
||||
itemConstraints = verticalCluesConstraints,
|
||||
itemCount = verticalClues.count
|
||||
)
|
||||
)
|
||||
|
||||
// Position the grid
|
||||
gridBoxPlaceable.place(0, 0)
|
||||
gridPlaceable.place(0, 0)
|
||||
|
||||
divider1Placeable.place(0, gridSize + spacingPx)
|
||||
|
||||
// Position the horizontal clues
|
||||
val horizontalCluesOffsetY = gridSize + 2 * spacingPx
|
||||
|
||||
horizontalCluesBoxPlaceable.place(0, horizontalCluesOffsetY)
|
||||
var offsetY = placeClues(
|
||||
placeables = horizontalCluesPlaceables,
|
||||
offsetX = 0,
|
||||
offsetY = horizontalCluesOffsetY,
|
||||
maxWidth = gridSize
|
||||
)
|
||||
|
||||
// Add divider in between
|
||||
divider2Placeable.place(0, offsetY + spacingPx)
|
||||
|
||||
// Position the vertical clues
|
||||
val verticalCluesOffsetY = offsetY + spacingPx + divider2Placeable.height
|
||||
verticalCluesBoxPlaceable.place(0, verticalCluesOffsetY)
|
||||
offsetY = placeClues(
|
||||
placeables = verticalCluesPlaceables,
|
||||
offsetX = 0,
|
||||
offsetY = verticalCluesOffsetY,
|
||||
maxWidth = gridSize
|
||||
)
|
||||
verticalCluesBoxPlaceable.place(0, verticalCluesOffsetY)
|
||||
|
||||
// Position the time
|
||||
val remainingSpace = constraints.maxHeight - offsetY
|
||||
if (remainingSpace < timePlaceable.height) {
|
||||
val scale = remainingSpace.toFloat() / timePlaceable.height.toFloat()
|
||||
if (scale > 0.1f) {
|
||||
timePlaceable.placeWithLayer(
|
||||
x = constraints.maxWidth - timePlaceable.width - spacingPx,
|
||||
y = constraints.maxHeight - timePlaceable.height
|
||||
) {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = (timePlaceable.width * (1 - scale)) / 2f
|
||||
translationY = (timePlaceable.height * (1 - scale)) / 2f
|
||||
}
|
||||
}
|
||||
} else {
|
||||
timePlaceable.place(
|
||||
x = constraints.maxWidth - timePlaceable.width - spacingPx,
|
||||
y = constraints.maxHeight - timePlaceable.height
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package ch.dissem.yaep.ui.common.layout
|
||||
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.Placeable
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Constraints.Companion.fixed
|
||||
import kotlin.math.min
|
||||
|
||||
internal fun Placeable.PlacementScope.squarish(
|
||||
constraints: Constraints,
|
||||
spacingPx: Int,
|
||||
grid: FocusGroup,
|
||||
horizontalClues: FocusGroup,
|
||||
verticalClues: FocusGroup,
|
||||
timeMeasurable: Measurable
|
||||
) {
|
||||
val gridSize = (7 * min(constraints.maxWidth, constraints.maxHeight)) / 10
|
||||
val gridItemSize = (gridSize - 12 * spacingPx) / 18
|
||||
val rightBarWidth = constraints.maxWidth - gridSize - spacingPx
|
||||
|
||||
val gridConstraints = fixed(gridSize, gridSize)
|
||||
val horizontalCluesConstraints = fixed(
|
||||
width = 3 * gridItemSize + 2 * spacingPx,
|
||||
height = gridItemSize + 2 * spacingPx
|
||||
)
|
||||
val verticalCluesConstraints = fixed(
|
||||
width = gridItemSize + 2 * spacingPx,
|
||||
height = 3 * gridItemSize + 2 * spacingPx
|
||||
)
|
||||
val timeConstraints = Constraints()
|
||||
|
||||
|
||||
val gridPlaceable = grid.item.measure(gridConstraints)
|
||||
val gridBoxPlaceable = grid.box.measure(gridConstraints)
|
||||
val horizontalCluesPlaceables = horizontalClues.items.map {
|
||||
it.measure(horizontalCluesConstraints)
|
||||
}
|
||||
val horizontalCluesBoxPlaceable = horizontalClues.box
|
||||
.measure(
|
||||
cluesBoxConstraints(
|
||||
width = rightBarWidth,
|
||||
itemConstraints = horizontalCluesConstraints,
|
||||
itemCount = horizontalClues.count
|
||||
)
|
||||
)
|
||||
|
||||
val verticalCluesPlaceables = verticalClues.items.map {
|
||||
it.measure(verticalCluesConstraints)
|
||||
}
|
||||
val verticalCluesBoxPlaceable = verticalClues.box
|
||||
.measure(
|
||||
cluesBoxConstraints(
|
||||
width = rightBarWidth,
|
||||
itemConstraints = verticalCluesConstraints,
|
||||
itemCount = verticalClues.count
|
||||
)
|
||||
)
|
||||
val timePlaceable = timeMeasurable.measure(timeConstraints)
|
||||
|
||||
// Position the grid
|
||||
gridBoxPlaceable.place(0, 0)
|
||||
gridPlaceable.place(0, 0)
|
||||
|
||||
// Position the horizontal clues
|
||||
val horizontalCluesOffsetX = gridSize + 2 * spacingPx
|
||||
horizontalCluesBoxPlaceable.place(horizontalCluesOffsetX, 0)
|
||||
placeClues(
|
||||
placeables = horizontalCluesPlaceables,
|
||||
offsetX = horizontalCluesOffsetX,
|
||||
offsetY = 0,
|
||||
maxWidth = rightBarWidth
|
||||
)
|
||||
|
||||
// Position the vertical clues
|
||||
val verticalCluesOffsetY = gridSize + 2 * spacingPx
|
||||
verticalCluesBoxPlaceable.place(0, verticalCluesOffsetY)
|
||||
placeClues(
|
||||
placeables = verticalCluesPlaceables,
|
||||
offsetX = 0,
|
||||
offsetY = verticalCluesOffsetY,
|
||||
maxWidth = gridSize
|
||||
)
|
||||
|
||||
// Position the time
|
||||
timePlaceable.place(
|
||||
x = constraints.maxWidth - timePlaceable.width - spacingPx,
|
||||
y = constraints.maxHeight - timePlaceable.height
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user