Недавно Google анонсировала следующую итерацию своего языка Material Design — Material 3 Expressive.
Как объясняют в Google, они хотят привнести индивидуальность и эмоции в скучные пользовательские интерфейсы приложений, помогая пользователям чувствовать себя более связанными со своими смартфонами, которые, по словам Google, больше не являются простыми инструментами, а являются продолжением их самих.
В новой версии Material 3 Expressive компания Google представила новую систему физики движений, призванную сделать пользовательский интерфейс приложений более живым, плавным и естественным.
По мере изучения возможностей Material 3 Expressive одним из первых компонентов, которые я собираюсь внедрить, станут кнопки-переключатели. Благодаря новой выразительной системе движений и визуальных эффектов мне не терпится увидеть, как эти кнопки могут стать более динамичными, плавными и эмоционально привлекательными — выйти за рамки статичных элементов пользовательского интерфейса и стать тем, что действительно реагирует на взаимодействие с пользователем.
Давайте начнем!
Для начала создадим 3 кнопки, как в превью Google, но сделаем это пошагово.
var selectedIndex by remember { mutableIntStateOf(-1) }
val selectedColor = Color(0xFF554F6E)
val unselectedColor = Color(0xFFEAE5FF)
val icons = listOf(Icons.Outlined.Alarm, Icons.Outlined.LinkOff, Icons.Outlined.Wifi)
val iconSize = 60.dp
Row(Modifier.padding(horizontal = 8.dp),) {
icons.forEachIndexed { index, icon ->
FloatingActionButton(
modifier = Modifier
.padding(4.dp)
.width(
when (index) {
1 -> iconSize / 1.5f
2 -> iconSize * 1.5f
else -> iconSize
}
)
.height(iconSize),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp
),
containerColor = if (selectedIndex == index) {
selectedColor
} else {
unselectedColor
},
contentColor = if (selectedIndex == index) {
unselectedColor
} else {
selectedColor
},
onClick = { selectedIndex = index },
) {
Icon(
imageVector = icon,
contentDescription = null,
)
}
}
}
Вот что мы получим в результате. Давайте сделаем так, чтобы это соответствовало примеру Google с изменением размеров, просто используя обычный размер Floating Action Button.
Теперь у нас есть все необходимое, не хватает только одной вещи. Давайте добавим немного анимации и сделаем интерфейс живым.
@Composable
fun ExpressiveButtonAnimation() {
var selectedIndex by remember { mutableIntStateOf(-1) }
val checkedColor = Color(0xFF554F6E)
val uncheckedColor = Color(0xFFEAE5FF)
val icons = listOf(Icons.Outlined.Alarm, Icons.Outlined.LinkOff, Icons.Outlined.Wifi)
var weights = remember { mutableStateListOf(0.85f, 0.65f, 1.5f) }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFDAD2FF)),
contentAlignment = Alignment.Center,
) {
Row(
modifier = Modifier
.fillMaxWidth(0.6f)
.padding(horizontal = 8.dp)
) {
icons.forEachIndexed { index, icon ->
ExpressiveFloatingActionButton(
icon = icon,
itemWeight = weights[index],
checked = selectedIndex == index,
checkedColor = checkedColor,
uncheckedColor = uncheckedColor,
onClick = {
selectedIndex = if (index == selectedIndex) {
-1
} else {
index
}
}
)
}
}
}
}
@Composable
fun RowScope.ExpressiveFloatingActionButton(
icon: ImageVector,
itemWeight: Float,
checked: Boolean,
checkedColor: Color,
uncheckedColor: Color,
onClick: () -> Unit,
) {
var shapeSelected by remember { mutableStateOf(false) }
val animatedRadius by animateDpAsState(
targetValue = if (shapeSelected) 6.dp else 16.dp,
label = "animatedRadius"
)
val animatedWeight by animateFloatAsState(
targetValue = if (shapeSelected) 0.25f else 0f
)
val iconSize = 70.dp
IconButton(
modifier = Modifier
.padding(4.dp)
.weight(itemWeight + animatedWeight)
.height(iconSize)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
event.changes.forEach { pointerInputChange ->
shapeSelected = pointerInputChange.pressed
}
}
}
},
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = if (shapeSelected || checked) checkedColor else uncheckedColor,
contentColor = if (shapeSelected || checked) uncheckedColor else checkedColor,
),
shape = MaterialTheme.shapes.large.copy(CornerSize(animatedRadius)),
onClick = onClick,
) {
Icon(
imageVector = icon,
contentDescription = null,
)
}
}
Я изменил фиксированную ширину элементов с помощью весов, чтобы пользовательский интерфейс имел одинаковые пропорции на всех размерах экрана.
И вот окончательный результат:
Я решил написать все это сам, но вы можете использовать и настраивать это по своему усмотрению.
Если вы не хотите разбираться с кастомными анимациями вручную, вы можете использовать ButtonGroup, который уже поддерживает все новые анимации. Однако имейте в виду, что в настоящее время она находится в стадии альфа-версии — это означает, что API еще нестабилен и может быть изменен в будущих релизах — и ее функциональность пока довольно ограничена.
Подробнее о M3 Expressive вы можете узнать здесь.

