Last active
March 18, 2024 09:27
-
-
Save bmc08gt/1d857aef24d60df1524b27dd34c9d402 to your computer and use it in GitHub Desktop.
Compose Multiplatform Segmented Control
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import androidx.compose.animation.core.animateFloatAsState | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.IntrinsicSize | |
import androidx.compose.foundation.layout.fillMaxHeight | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.shape.CircleShape | |
import androidx.compose.material3.MaterialTheme | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.getValue | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.draw.clip | |
import androidx.compose.ui.draw.drawWithContent | |
import androidx.compose.ui.geometry.CornerRadius | |
import androidx.compose.ui.geometry.Offset | |
import androidx.compose.ui.geometry.Size | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.semantics.Role | |
import androidx.compose.ui.text.font.FontWeight | |
import com.airbnb.sample.theme.dimens | |
import com.airbnb.sample.utils.ui.NoRippleInteractionSource | |
@Composable | |
actual fun <T> SegmentedControl( | |
modifier: Modifier, | |
options: List<T>, | |
selected: T, | |
onOptionClicked: (T) -> Unit, | |
titleForItem: (T) -> String, | |
) { | |
val contentMargin = MaterialTheme.dimens.staticGrid.x2 | |
val offset by animateFloatAsState( | |
when (options.indexOf(selected)) { | |
0 -> 0f | |
1 -> 1f | |
2 -> 2f | |
else -> 0f | |
} | |
) | |
androidx.compose.foundation.layout.Row( | |
modifier = modifier | |
.height(IntrinsicSize.Min) | |
.background( | |
Color(0xFFEEEEEF), | |
CircleShape, | |
) | |
.drawWithContent { | |
drawRoundRect( | |
color = Color.White, | |
topLeft = Offset( | |
contentMargin.value + (size.width / 3f * offset), | |
contentMargin.value | |
), | |
size = Size( | |
this.size.width / 3f - contentMargin.value * 2, | |
this.size.height - contentMargin.value * 2 | |
), | |
cornerRadius = CornerRadius(this.size.height / 2) | |
) | |
drawContent() | |
} | |
.clip(CircleShape), | |
verticalAlignment = Alignment.CenterVertically, | |
) { | |
options.onEachIndexed { index, segment -> | |
Box( | |
Modifier | |
.fillMaxHeight() | |
.clip(CircleShape) | |
.clickable( | |
enabled = true, | |
onClickLabel = titleForItem(segment), | |
indication = null, | |
role = Role.Button, | |
interactionSource = NoRippleInteractionSource() | |
) { onOptionClicked(segment) } | |
.padding(contentMargin) | |
// Divide space evenly between all segments. | |
.weight(1f), | |
contentAlignment = Alignment.Center, | |
) { | |
Text( | |
text = titleForItem(segment), | |
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.W600) | |
) | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.width | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.setValue | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.interop.UIKitView | |
import androidx.compose.ui.unit.dp | |
import com.airbnb.sample.utils.ui.addIf | |
import kotlinx.cinterop.ExperimentalForeignApi | |
import kotlinx.cinterop.convert | |
import kotlinx.cinterop.useContents | |
import platform.UIKit.UIAction.Companion.actionWithHandler | |
import platform.UIKit.UIImageView | |
import platform.UIKit.UISegmentedControl | |
import platform.darwin.NSInteger | |
import platform.darwin.NSUInteger | |
@OptIn(ExperimentalForeignApi::class) | |
@Composable | |
actual fun <T> SegmentedControl( | |
modifier: Modifier, | |
options: List<T>, | |
selected: T, | |
onOptionClicked: (T) -> Unit, | |
titleForItem: (T) -> String, | |
) { | |
val controlManager = remember(options) { | |
SegmentedControlManager( | |
options = options, | |
titleForItem = titleForItem, | |
onSelectionChanged = onOptionClicked, | |
) | |
} | |
LaunchedEffect(selected) { | |
if (controlManager.selected != selected) { | |
controlManager.setSelected(selected) | |
} | |
} | |
UIKitView( | |
modifier = modifier | |
.addIf(controlManager.segmentedControlWidth > 0) { | |
Modifier.width(controlManager.segmentedControlWidth.dp) | |
} | |
.addIf(controlManager.segmentedControlHeight > 0) { | |
Modifier.height(controlManager.segmentedControlHeight.dp) | |
}, | |
factory = { | |
return@UIKitView controlManager.controller | |
} | |
) | |
} | |
@OptIn(ExperimentalForeignApi::class) | |
class SegmentedControlManager<T> internal constructor( | |
private val options: List<T>, | |
private val titleForItem: (T) -> String, | |
private val onSelectionChanged: (option: T) -> Unit, | |
) { | |
var segmentedControlWidth by mutableStateOf(0f) | |
private set | |
var segmentedControlHeight by mutableStateOf(0f) | |
private set | |
val controller: UISegmentedControl = UISegmentedControl(options.map { it!!::class.simpleName }) | |
init { | |
options.forEachIndexed { index, item -> | |
controller.setAction(action = actionWithHandler { onSelectionChanged(item) }, forSegmentAtIndex = index.convert<NSUInteger>()) | |
controller.setTitle(titleForItem(item), forSegmentAtIndex = index.convert<NSUInteger>()) | |
} | |
controller.selectedSegmentIndex = 0.convert<NSInteger>() | |
controller.frame.useContents { | |
segmentedControlWidth = this.size.width.toFloat() | |
segmentedControlHeight = this.size.height.toFloat() | |
controller.layer.cornerRadius = size.width / 2 | |
controller.layer.masksToBounds = true | |
(controller.subviews[0] as? UIImageView)?.layer?.cornerRadius = controller.layer.cornerRadius | |
} | |
} | |
fun setSelected(option: T) { | |
options.indexOf(option).takeIf { it >= 0 }?.let { | |
controller.selectedSegmentIndex = it.convert<NSInteger>() | |
} | |
} | |
val selected: T? | |
get() = controller.selectedSegmentIndex.let { | |
runCatching { options[it.toInt()] }.getOrNull() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment