My problem is that i need a tab indicator to match exactly to the text that is above it (from designs):
However, all i managed to do is get something looking like this:
My code:
ScrollableTabRow(
selectedTabIndex = selectedSeason,
backgroundColor = Color.White,
edgePadding = 0.dp,
modifier = Modifier
.padding(vertical = 24.dp)
.height(40.dp),
indicator = { tabPositions ->
TabDefaults.Indicator(
color = Color.Red,
height = 4.dp,
modifier = Modifier
.tabIndicatorOffset(tabPositions[selectedSeason])
)
}
) {
item.seasonList().forEachIndexed { index, contentItem ->
Tab(
modifier = Modifier.padding(bottom = 10.dp),
selected = index == selectedSeason,
onClick = { selectedSeason = index }
)
{
Text(
"Season " + contentItem.seasonNumber(),
color = Color.Black,
style = styles.seasonBarTextStyle(index == selectedSeason)
)
}
}
}
}
Also a little bonus question, my code for this screen is inside lazy column, now i need to have this tab row to behave somewhat like a sticky header(when it gets to the top, screen stops scrolling, but i can still scroll the items inside it)
Thanks for your help
I had the same requirement, and came up with a simpler solution. By putting the same horizontal padding on the Tab and on the indicator, the indicator aligns with the tab's content:
ScrollableTabRow(selectedTabIndex = tabIndex,
indicator = { tabPositions ->
Box(
Modifier
.tabIndicatorOffset(tabPositions[tabIndex])
.height(TabRowDefaults.IndicatorHeight)
.padding(end = 20.dp)
.background(color = Color.White)
)
}) {
Tab(modifier = Modifier.padding(end = 20.dp, bottom = 8.dp),
selected = tabIndex == 0, onClick = { tabIndex = 0}) {
Text(text = "Tab 1!")
}
Tab(modifier = Modifier.padding(end = 20.dp, bottom = 8.dp),
selected = tabIndex == 1, onClick = { tabIndex = 1}) {
Text(text = "Tab 2!")
}
}
Have a look at the provided modifier, it internally computes a width value. If you change the Modifier yourself to the code below you can provide a width value.
fun Modifier.ownTabIndicatorOffset(
currentTabPosition: TabPosition,
currentTabWidth: Dp = currentTabPosition.width
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "tabIndicatorOffset"
value = currentTabPosition
}
) {
val indicatorOffset by animateAsState(
targetValue = currentTabPosition.left,
animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)
)
fillMaxWidth()
.wrapContentSize(Alignment.BottomStart)
.offset(x = indicatorOffset + ((currentTabPosition.width - currentTabWidth) / 2))
.preferredWidth(currentTabWidth)
}
Now to the point of how to get the width of your Text:
Warning: I think it's not the way to do it but I can't figure out a better one atm.
At first, I create a Composable to provide me the width of its contents.
#Composable
fun MeasureWidthOf(setWidth: (Int) -> Unit, content: #Composable () -> Unit) {
Layout(
content = content
) { list: List<Measurable>, constraints: Constraints ->
check(list.size == 1)
val placeable = list.last().measure(constraints)
layout(
width = placeable.width.also(setWidth),
height = placeable.height
) {
placeable.placeRelative(x = 0, y = 0)
}
}
}
Now I can use it in your example (simplified):
// Needed for Android
fun Float.asPxtoDP(density: Float): Dp {
return (this / (density)).dp
}
fun main(args: Array<String>) {
Window(size = IntSize(600, 800)) {
val (selectedSeason, setSelectedSeason) = remember { mutableStateOf(0) }
val seasonsList = mutableListOf(2020, 2021, 2022)
val textWidth = remember { mutableStateListOf(0, 0, 0) }
// Android
val density = AmbientDensity.current.density
ScrollableTabRow(
selectedTabIndex = selectedSeason,
backgroundColor = Color.White,
edgePadding = 0.dp,
modifier = Modifier
.padding(vertical = 24.dp)
.height(40.dp),
indicator = { tabPositions ->
TabDefaults.Indicator(
color = Color.Red,
height = 4.dp,
modifier = Modifier
.ownTabIndicatorOffset(
currentTabPosition = tabPositions[selectedSeason],
// Android:
currentTabWidth = textWidth[selectedSeason].asPxtoDP(density)
// Desktop:
currentTabWidth = textWidth[selectedSeason].dp
)
)
}
) {
seasonsList.forEachIndexed { index, contentItem ->
Tab(
modifier = Modifier.padding(bottom = 10.dp),
selected = index == selectedSeason,
onClick = { setSelectedSeason(index) }
)
{
val text = #Composable {
Text(
text = "Season $contentItem",
color = Color.Black,
textAlign = TextAlign.Center
)
}
if (index == selectedSeason) {
MeasureWidthOf(setWidth = { textWidth[index] = it }) {
text()
}
} else {
text()
}
}
}
}
}
}
Edit (05.01.2021): Simplified Modifier code
Edit (09.01.2021): Fixed density problem on android and tested on Desktop and Android
Related
What I want
Top fixed item with status bar padding and adaptive radius
Bottom fixed item with navigation bar padding
Adaptive center item, have enough room => Not scrollable, if not => scrollable
Current status
For full demo click this link
Only problem here is when there is no extra space, and we are dragging the scrollable list.
I think it's a bug, because everything is fine except the scrollable column.
window.height - sheetContent.height >= statusBarHeight
Everything is fine.
window.height - sheetContent.height < statusBarHeight
Dragging top fixed item or bottom fixed item, scroll still acting well.
Dragging the scrollable list, sheet pops back to top when the sheetState.offset is approaching statusBarHeight
Test youself
You can test it with these 3 functions, for me, I'm using Pixel 2 Emulator, itemCount at 18,19 could tell the difference.
#Composable
fun CEModalBottomSheet(
sheetState: ModalBottomSheetState,
onCloseDialogClicked: () -> Unit,
title: String = "BottomSheet Title",
toolbarElevation: Dp = 0.dp,
sheetContent: #Composable ColumnScope.() -> Unit,
) {
val density = LocalDensity.current
val sheetOffset = sheetState.offset
val statusBar = WindowInsets.statusBars.asPaddingValues()
val shapeRadius by animateDpAsState(
if (sheetOffset.value < with(density) { statusBar.calculateTopPadding().toPx() }) {
0.dp
} else 12.dp
)
val dynamicStatusBarPadding by remember {
derivedStateOf {
val statusBarHeightPx2 = with(density) { statusBar.calculateTopPadding().toPx() }
val offsetValuePx = sheetOffset.value
if (offsetValuePx >= statusBarHeightPx2) {
0.dp
} else {
with(density) { (statusBarHeightPx2 - offsetValuePx).toDp() }
}
}
}
ModalBottomSheetLayout(
sheetState = sheetState,
sheetShape = RoundedCornerShape(topStart = shapeRadius, topEnd = shapeRadius),
content = {},
sheetContent = {
Column(modifier = Modifier.fillMaxWidth()) {
TopTitleItemForDialog(
title = title,
elevation = toolbarElevation,
topPadding = dynamicStatusBarPadding,
onClick = onCloseDialogClicked
)
sheetContent()
}
})
}
#Composable
fun TopTitleItemForDialog(
title: String,
elevation: Dp = 0.dp,
topPadding: Dp = 0.dp,
onClick: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color.LightGray,
elevation = elevation
) {
Row(
modifier = Modifier.padding(top = topPadding),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.size(16.dp))
Text(
text = title,
maxLines = 1,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onClick) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.cancel),
tint = Color.Gray,
modifier = Modifier.size(24.dp)
)
}
}
}
}
class SheetPaddingTestActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.statusBarColor = android.graphics.Color.TRANSPARENT
window.navigationBarColor = android.graphics.Color.TRANSPARENT
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
Box(
Modifier
.fillMaxSize()
.background(Color.Green), contentAlignment = Alignment.Center
) {
var itemCount by remember { mutableStateOf(20) }
val state = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) { false }
val scope = rememberCoroutineScope()
Text("显示弹窗", modifier = Modifier.clickable {
scope.launch { state.animateTo(ModalBottomSheetValue.Expanded) }
})
CEModalBottomSheet(sheetState = state,
onCloseDialogClicked = {
scope.launch {
state.hide()
}
}, sheetContent = {
Column(
Modifier
.verticalScroll(rememberScrollState())
.weight(1f, fill = false)
.padding(horizontal = 16.dp),
) {
repeat(itemCount) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(30.dp)
.background(Color.Blue.copy(alpha = 1f - it * 0.04f))
)
}
}
CompositionLocalProvider(
LocalContentColor.provides(Color.White)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.primary)
.padding(vertical = 12.dp)
.navigationBarsPadding(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(modifier = Modifier.weight(1f),
onClick = {
itemCount = max(itemCount - 1, 0)
}) {
Icon(Icons.Default.KeyboardArrowLeft, "")
}
Text(
modifier = Modifier.weight(1f), text = "$itemCount",
textAlign = TextAlign.Center
)
IconButton(modifier = Modifier.weight(1f),
onClick = {
itemCount++
}) {
Icon(Icons.Default.KeyboardArrowRight, "")
}
}
}
}
)
}
}
}
}
In the shopping list item composable, I am reducing the alpha value on the whole item by changing the alpha value on it's parent Row composable, but I want to exclude a child Icon composable from receiving the parent Row's alpha change and retain a 100% alpha value. I set the modifier on the child Icon to alpha(1f), but it is not working. The alpha change on the parent is also propagating to the child despite this. Is it possible exclude the child from the parent's alpha change?
Composable
#Composable
fun ShoppingListScreenItem(
rowModifier: Modifier = Modifier,
item: ShoppingListItem,
mainViewModel: ShoppingListScreenViewModel,
onNavigateToAddEditItemScreenFromItemStrip: (ShoppingListItem) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 3.dp, bottom = 5.dp)
.then(rowModifier)
.alpha(if (item.isItemDisabled) 0.35f else 1f)
.clickable {
onNavigateToAddEditItemScreenFromItemStrip(item)
},
verticalAlignment = Alignment.CenterVertically,
) {
...
if(mainViewModel.shoppingListState.value!!.sortOrder != "Custom" && !item.isInCart) {
//I want to exclude this child composable
IconToggleButton(
checked = item.isItemDisabled,
onCheckedChange = {
scope.launch {
mainViewModel.updateShoppingListItemDisabledInDb(item, it)
}
},
) {
Icon(
modifier = Modifier.alpha(1f).size(26.dp),
painter = painterResource(id = R.drawable.baseline_disabled_visible_24),
contentDescription = "Toggle disable the item strip",
)
}
}
}
}
This can be achieved using Layout and placeable.placeWithLayer and Modifier.layoutId to select which Composable is to be used with default alpha as.
This is a custom Column, you can customize Layout as required, purpose is to show Modifier.layoutId usage and Placeable.placeRelativeWithLayer to apply any desired graphic layer property to specific Composable in layout phase.
Result
Usage
MyLayout(
alpha = .5f
) {
Text("Default Alpha", fontSize = 20.sp)
Text("Default Alpha", fontSize = 20.sp)
Text("Custom Alpha", fontSize = 20.sp), modifier = Modifier.layoutId("full_alpha"))
Text("Default Alpha", fontSize = 20.sp)
Image(painter = painterResource(id = R.drawable.landscape5), contentDescription = "")
}
Implementation
#Composable
private fun MyLayout(
modifier: Modifier = Modifier,
alpha: Float = 1f,
content: #Composable () -> Unit
) {
val measurePolicy = MeasurePolicy { measurables, constraints ->
val fullAlphaIndex = measurables.indexOfFirst {
it.layoutId == "full_alpha"
}
val placeablesWidth = measurables.map { measurable ->
measurable.measure(constraints)
}
val hasBoundedWidth = constraints.hasBoundedWidth
val hasFixedWidth = constraints.hasFixedWidth
val width =
if (hasBoundedWidth && hasFixedWidth) constraints.maxWidth
else placeablesWidth.maxOf { it.width }
val height = placeablesWidth.sumOf {
it.height
}
var posY = 0
layout(width, height) {
placeablesWidth.forEachIndexed { index, placeable ->
placeable.placeRelativeWithLayer(0, posY) {
if (index == fullAlphaIndex) {
this.alpha = 1f
} else {
this.alpha = alpha
}
}
posY += placeable.height
}
}
}
Layout(
modifier = modifier,
content = content,
measurePolicy = measurePolicy
)
}
If you wish to create a Row you need to place items one after other horizontally instead of increasing y position
Result
#Composable
fun MyLayout(
modifier: Modifier = Modifier,
alpha: Float = 1f,
content: #Composable () -> Unit
) {
val measurePolicy = MeasurePolicy { measurables, constraints ->
val fullAlphaIndex = measurables.indexOfFirst {
it.layoutId == "full_alpha"
}
val placeablesWidth = measurables.map { measurable ->
measurable.measure(
constraints.copy(
minWidth = 0,
maxWidth = Constraints.Infinity,
minHeight = 0,
maxHeight = Constraints.Infinity
)
)
}
val hasBoundedWidth = constraints.hasBoundedWidth
val hasFixedWidth = constraints.hasFixedWidth
val hasBoundedHeight = constraints.hasBoundedHeight
val hasFixedHeight = constraints.hasFixedHeight
val width =
if (hasBoundedWidth && hasFixedWidth) constraints.maxWidth
else placeablesWidth.sumOf { it.width }.coerceAtMost(constraints.maxWidth)
val height =
if (hasBoundedHeight && hasFixedHeight) constraints.maxHeight
else placeablesWidth.maxOf { it.height }.coerceAtMost(constraints.maxHeight)
var posX = 0
layout(width, height) {
placeablesWidth.forEachIndexed { index, placeable ->
placeable.placeRelativeWithLayer(posX, 0) {
if (index == fullAlphaIndex) {
this.alpha = 1f
} else {
this.alpha = alpha
}
}
posX += placeable.width
}
}
}
Layout(
modifier = modifier,
content = content,
measurePolicy = measurePolicy
)
}
Same usage but this time with Icons
MyLayout(
modifier = Modifier.drawChecker(),
alpha = .4f
) {
Icon(
imageVector = Icons.Default.NotificationsActive,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.background(Color.Red, CircleShape)
.size(100.dp)
.padding(10.dp)
)
Icon(
imageVector = Icons.Default.NotificationsActive,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.background(Color.Red, CircleShape)
.size(100.dp)
.padding(10.dp)
)
Icon(
imageVector = Icons.Default.NotificationsActive,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.layoutId("full_alpha")
.background(Color.Red, CircleShape)
.size(100.dp)
.padding(10.dp)
)
Icon(
imageVector = Icons.Default.NotificationsActive,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.background(Color.Red, CircleShape)
.size(100.dp)
.padding(10.dp)
)
}
Checker Modifier if anyone wonders is as
fun Modifier.drawChecker() = this.then(
drawBehind {
val width = this.size.width
val height = this.size.height
val checkerWidth = 10.dp.toPx()
val checkerHeight = 10.dp.toPx()
val horizontalSteps = (width / checkerWidth).toInt()
val verticalSteps = (height / checkerHeight).toInt()
for (y in 0..verticalSteps) {
for (x in 0..horizontalSteps) {
val isGrayTile = ((x + y) % 2 == 1)
drawRect(
color = if (isGrayTile) Color.LightGray else Color.White,
topLeft = Offset(x * checkerWidth, y * checkerHeight),
size = Size(checkerWidth, checkerHeight)
)
}
}
}
)
How to clip or cut Composable content to have Image, Button or Composables to have custom shapes? This question is not about using Modifier.clip(), more like accomplishing task with alternative methods that allow outcomes that are not possible or when it's difficult to create a shape like cloud or Squircle.
This is share your knowledge, Q&A-style question which inspired by M3 BottomAppBar or BottomNavigation not having cutout shape, couldn't find question, and drawing a Squircle shape being difficult as in this question.
More and better ways of clipping or customizing shapes and Composables are more than welcome.
One of the ways for achieving cutting or clipping a Composable without the need of creating a custom Composable is using
Modifier.drawWithContent{} with a layer and a BlendMode or PorterDuff modes.
With Jetpack Compose for these modes to work you either need to set alpha less than 1f or use a Layer as in answer here.
I go with layer solution because i don't want to change content alpha
fun ContentDrawScope.drawWithLayer(block: ContentDrawScope.() -> Unit) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
block()
restoreToCount(checkPoint)
}
}
block lambda is the draw scope for Modifier.drawWithContent{} to do clipping
and another extension for simplifying further
fun Modifier.drawWithLayer(block: ContentDrawScope.() -> Unit) = this.then(
Modifier.drawWithContent {
drawWithLayer {
block()
}
}
)
Clip button at the left side
First let's draw the button that is cleared a circle at left side
#Composable
private fun WhoAteMyButton() {
val circleSize = LocalDensity.current.run { 100.dp.toPx() }
Box(
modifier = Modifier
.fillMaxWidth()
.drawWithLayer {
// Destination
drawContent()
// Source
drawCircle(
center = Offset(0f, 10f),
radius = circleSize,
blendMode = BlendMode.SrcOut,
color = Color.Transparent
)
}
) {
Button(
modifier = Modifier
.padding(horizontal = 10.dp)
.fillMaxWidth(),
onClick = { /*TODO*/ }) {
Text("Hello World")
}
}
}
We simply draw a circle but because of BlendMode.SrcOut intersection of destination is removed.
Clip button and Image with custom image
For squircle button i found an image from web
And clipped button and image using this image with
#Composable
private fun ClipComposables() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
val imageBitmap = ImageBitmap.imageResource(id = R.drawable.squircle)
Box(modifier = Modifier
.size(150.dp)
.drawWithLayer {
// Destination
drawContent()
// Source
drawImage(
image = imageBitmap,
dstSize = IntSize(width = size.width.toInt(), height = size.height.toInt()),
blendMode = BlendMode.DstIn
)
}
) {
Box(
modifier = Modifier
.size(150.dp)
.clickable { }
.background(MaterialTheme.colorScheme.inversePrimary),
contentAlignment = Alignment.Center
) {
Text(text = "Squircle", fontSize = 20.sp)
}
}
Box(modifier = Modifier
.size(150.dp)
.drawWithLayer {
// Destination
drawContent()
// Source
drawImage(
image = imageBitmap,
dstSize = IntSize(width = size.width.toInt(), height = size.height.toInt()),
blendMode = BlendMode.DstIn
)
}
) {
Image(
painterResource(id = R.drawable.squirtle),
modifier = Modifier
.size(150.dp),
contentScale = ContentScale.Crop,
contentDescription = ""
)
}
}
}
There are 2 things to note here
1- Blend mode is BlendMode.DstIn because we want texture of Destination with shape of Source
2- Drawing image inside ContentDrawScope with dstSize to match Composable size. By default it's drawn with png size posted above.
Creating a BottomNavigation with cutout shape
#Composable
private fun BottomBarWithCutOutShape() {
val density = LocalDensity.current
val shapeSize = density.run { 70.dp.toPx() }
val cutCornerShape = CutCornerShape(50)
val outline = cutCornerShape.createOutline(
Size(shapeSize, shapeSize),
LocalLayoutDirection.current,
density
)
val icons =
listOf(Icons.Filled.Home, Icons.Filled.Map, Icons.Filled.Settings, Icons.Filled.LocationOn)
Box(
modifier = Modifier.fillMaxWidth()
) {
BottomNavigation(
modifier = Modifier
.drawWithLayer {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
val width = size.width
val outlineWidth = outline.bounds.width
val outlineHeight = outline.bounds.height
// Destination
drawContent()
// Source
withTransform(
{
translate(
left = (width - outlineWidth) / 2,
top = -outlineHeight / 2
)
}
) {
drawOutline(
outline = outline,
color = Color.Transparent,
blendMode = BlendMode.Clear
)
}
restoreToCount(checkPoint)
}
},
backgroundColor = Color.White
) {
var selectedIndex by remember { mutableStateOf(0) }
icons.forEachIndexed { index, imageVector: ImageVector ->
if (index == 2) {
Spacer(modifier = Modifier.weight(1f))
BottomNavigationItem(
icon = { Icon(imageVector, contentDescription = null) },
label = null,
selected = selectedIndex == index,
onClick = {
selectedIndex = index
}
)
} else {
BottomNavigationItem(
icon = { Icon(imageVector, contentDescription = null) },
label = null,
selected = selectedIndex == index,
onClick = {
selectedIndex = index
}
)
}
}
}
// This is size fo BottomNavigationItem
val bottomNavigationHeight = LocalDensity.current.run { 56.dp.roundToPx() }
FloatingActionButton(
modifier = Modifier
.align(Alignment.TopCenter)
.offset {
IntOffset(0, -bottomNavigationHeight / 2)
},
shape = cutCornerShape,
onClick = {}
) {
Icon(imageVector = Icons.Default.Add, contentDescription = null)
}
}
}
This code is a bit long but we basically create a shape like we always and create an outline to clip
val cutCornerShape = CutCornerShape(50)
val outline = cutCornerShape.createOutline(
Size(shapeSize, shapeSize),
LocalLayoutDirection.current,
density
)
And before clipping we move this shape section up as half of the height to cut only with half of the outline
withTransform(
{
translate(
left = (width - outlineWidth) / 2,
top = -outlineHeight / 2
)
}
) {
drawOutline(
outline = outline,
color = Color.Transparent,
blendMode = BlendMode.Clear
)
}
Also to have a BottomNavigation such as BottomAppBar that places children on both side
i used a Spacer
icons.forEachIndexed { index, imageVector: ImageVector ->
if (index == 2) {
Spacer(modifier = Modifier.weight(1f))
BottomNavigationItem(
icon = { Icon(imageVector, contentDescription = null) },
label = null,
selected = selectedIndex == index,
onClick = {
selectedIndex = index
}
)
} else {
BottomNavigationItem(
icon = { Icon(imageVector, contentDescription = null) },
label = null,
selected = selectedIndex == index,
onClick = {
selectedIndex = index
}
)
}
}
Then we simply add a FloatingActionButton, i used offset but you can create a bigger parent and put our custom BottomNavigation and button inside it.
I have my composable which I am displaying the content according to some states. First time I open the screen everything works fine but when I move my app to background and then come back it stops being recomposed. When I try to debug it I can see that the state is changing but for some reason my view is not being updated. Here is my composable:
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val isKeyboardOpen by keyboardAsState()
var buttonHeight by remember { mutableStateOf(0.dp) }
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.verticalScroll(scrollState)
.background(color = White),
) {
Text(
modifier = Modifier.padding(
start = 18.dp,
end = 18.dp,
top = 22.dp,
bottom = 12.dp
),
text = "text",
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface
)
BasicTextField(
modifier = Modifier
.padding(horizontal = 18.dp)
.onGloballyPositioned { coordinates ->
if (isKeyboardOpen == Keyboard.Opened) {
coroutineScope.launch {
scrollState.animateScrollTo(coordinates.positionInParent().y.roundToInt())
}
}
},
onValueChange = { onEvent.invoke(Event.ReasonFieldValueChanged(it)) },
onFocused = { onEvent.invoke(Event.ReasonFieldFocused) }
)
Card(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 24.dp),
backgroundColor = MaterialTheme.colors.background,
elevation = 0.dp
) {
Text(
modifier = Modifier.padding(16.dp),
text = "text",
style = MaterialTheme.typography.body1.secondary
)
}
if (isKeyboardOpen == Keyboard.Closed) {
Spacer(modifier = Modifier.weight(1f))
CloseAccountButton(state, onEvent, isKeyboardOpen) {
buttonHeight = it
}
} else {
Spacer(modifier = Modifier.size(buttonHeight))
}
}
if (isKeyboardOpen == Keyboard.Opened) {
CloseAccountButton(state, onEvent, isKeyboardOpen) {
buttonHeight = it
}
}
And here is how I get keyboard state:
enum class Keyboard {
Opened, Closed
}
#Composable
fun keyboardAsState(): State<Keyboard> {
val keyboardState = remember { mutableStateOf(Keyboard.Closed) }
val view = LocalView.current
DisposableEffect(view) {
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
Keyboard.Opened
} else {
Keyboard.Closed
}
}
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
onDispose {
view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
}
}
return keyboardState
}
As I said on first opening of the screen everything is fine but when coming back from background neither scrolling to position nor raacting to keyboard visibility is working. I don't have much experience with compose, what is wrong here?
I want to have a horizontal dot indicator that has color transition between two dots that is scrolling and also dot's size transition while scrolling
I need to show only limited dots for a huge amount of items.
In view system, we used this library https://github.com/Tinkoff/ScrollingPagerIndicator, which is very smooth and has a very nice color and size transition effects.
I tried to implement it with scroll state rememberLazyListState(), but it is more complex than I thought.
Do you know any solution in Jetpack Compose?
Is it possible to use the current library with AndroidView? Because it needs XML view, recycler view and viewpager, I am wondering how is it possible to use it with AndroidView?
I made a sample looks similar, logic for scaling is raw but it looks similar.
#OptIn(ExperimentalPagerApi::class)
#Composable
fun PagerIndicator(
modifier: Modifier = Modifier,
pagerState: PagerState,
indicatorCount: Int = 5,
indicatorSize: Dp = 16.dp,
indicatorShape: Shape = CircleShape,
space: Dp = 8.dp,
activeColor: Color = Color(0xffEC407A),
inActiveColor: Color = Color.LightGray,
orientation: IndicatorOrientation = IndicatorOrientation.Horizontal,
onClick: ((Int) -> Unit)? = null
) {
val listState = rememberLazyListState()
val totalWidth: Dp = indicatorSize * indicatorCount + space * (indicatorCount - 1)
val widthInPx = LocalDensity.current.run { indicatorSize.toPx() }
val currentItem by remember {
derivedStateOf {
pagerState.currentPage
}
}
val itemCount = pagerState.pageCount
LaunchedEffect(key1 = currentItem) {
val viewportSize = listState.layoutInfo.viewportSize
if (orientation == IndicatorOrientation.Horizontal) {
listState.animateScrollToItem(
currentItem,
(widthInPx / 2 - viewportSize.width / 2).toInt()
)
} else {
listState.animateScrollToItem(
currentItem,
(widthInPx / 2 - viewportSize.height / 2).toInt()
)
}
}
if (orientation == IndicatorOrientation.Horizontal) {
LazyRow(
modifier = modifier.width(totalWidth),
state = listState,
contentPadding = PaddingValues(vertical = space),
horizontalArrangement = Arrangement.spacedBy(space),
userScrollEnabled = false
) {
indicatorItems(
itemCount,
currentItem,
indicatorCount,
indicatorShape,
activeColor,
inActiveColor,
indicatorSize,
onClick
)
}
} else {
LazyColumn(
modifier = modifier.height(totalWidth),
state = listState,
contentPadding = PaddingValues(horizontal = space),
verticalArrangement = Arrangement.spacedBy(space),
userScrollEnabled = false
) {
indicatorItems(
itemCount,
currentItem,
indicatorCount,
indicatorShape,
activeColor,
inActiveColor,
indicatorSize,
onClick
)
}
}
}
private fun LazyListScope.indicatorItems(
itemCount: Int,
currentItem: Int,
indicatorCount: Int,
indicatorShape: Shape,
activeColor: Color,
inActiveColor: Color,
indicatorSize: Dp,
onClick: ((Int) -> Unit)?
) {
items(itemCount) { index ->
val isSelected = (index == currentItem)
// Index of item in center when odd number of indicators are set
// for 5 indicators this is 2nd indicator place
val centerItemIndex = indicatorCount / 2
val right1 =
(currentItem < centerItemIndex &&
index >= indicatorCount - 1)
val right2 =
(currentItem >= centerItemIndex &&
index >= currentItem + centerItemIndex &&
index < itemCount - centerItemIndex + 1)
val isRightEdgeItem = right1 || right2
// Check if this item's distance to center item is smaller than half size of
// the indicator count when current indicator at the center or
// when we reach the end of list. End of the list only one item is on edge
// with 10 items and 7 indicators
// 7-3= 4th item can be the first valid left edge item and
val isLeftEdgeItem =
index <= currentItem - centerItemIndex &&
currentItem > centerItemIndex &&
index < itemCount - indicatorCount + 1
Box(
modifier = Modifier
.graphicsLayer {
val scale = if (isSelected) {
1f
} else if (isLeftEdgeItem || isRightEdgeItem) {
.5f
} else {
.8f
}
scaleX = scale
scaleY = scale
}
.clip(indicatorShape)
.size(indicatorSize)
.background(
if (isSelected) activeColor else inActiveColor,
indicatorShape
)
.then(
if (onClick != null) {
Modifier
.clickable {
onClick.invoke(index)
}
} else Modifier
)
)
}
}
enum class IndicatorOrientation {
Horizontal, Vertical
}
Usage
#Composable
private fun PagerIndicatorSample() {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(40.dp))
val pagerState1 = rememberPagerState(initialPage = 0)
val coroutineScope = rememberCoroutineScope()
PagerIndicator(pagerState = pagerState1) {
coroutineScope.launch {
pagerState1.scrollToPage(it)
}
}
HorizontalPager(
count = 10,
state = pagerState1,
) {
Box(
modifier = Modifier
.padding(10.dp)
.shadow(1.dp, RoundedCornerShape(8.dp))
.background(Color.White)
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Text(
"Text $it",
fontSize = 40.sp,
color = Color.Gray
)
}
}
val pagerState2 = rememberPagerState(initialPage = 0)
PagerIndicator(
pagerState = pagerState2,
indicatorSize = 24.dp,
indicatorCount = 7,
activeColor = Color(0xffFFC107),
inActiveColor = Color(0xffFFECB3),
indicatorShape = CutCornerShape(10.dp)
)
HorizontalPager(
count = 10,
state = pagerState2,
) {
Box(
modifier = Modifier
.padding(10.dp)
.shadow(1.dp, RoundedCornerShape(8.dp))
.background(Color.White)
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Text(
"Text $it",
fontSize = 40.sp,
color = Color.Gray
)
}
}
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
val pagerState3 = rememberPagerState(initialPage = 0)
Spacer(modifier = Modifier.width(10.dp))
PagerIndicator(
pagerState = pagerState3,
orientation = IndicatorOrientation.Vertical
)
Spacer(modifier = Modifier.width(20.dp))
VerticalPager(
count = 10,
state = pagerState3,
) {
Box(
modifier = Modifier
.padding(10.dp)
.shadow(1.dp, RoundedCornerShape(8.dp))
.background(Color.White)
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Text(
"Text $it",
fontSize = 40.sp,
color = Color.Gray
)
}
}
}
}
}
Need to convert from
listState.animateScrollToItem()
to
listState.animateScrollBy()
for smooth indicator change and moving with offset change from Pager.
and do some more methodical scale and color and offset calculating this one is only a temporary solution.
I could integrate this library https://github.com/Tinkoff/ScrollingPagerIndicator easily with compose.
#Composable
fun DotIndicator(scrollState: LazyListState, modifier: Modifier = Modifier) {
AndroidViewBinding(
modifier = modifier,
factory = DotIndicatorBinding::inflate,
) {
dotIndicator.setDotCount(scrollState.layoutInfo.totalItemsCount)
dotIndicator.setCurrentPosition(scrollState.firstVisibleItemIndex)
scrollState.layoutInfo.visibleItemsInfo.firstOrNull()?.size?.let { firstItemSize ->
val firstItemOffset = scrollState.firstVisibleItemScrollOffset
val offset = (firstItemOffset.toFloat() / firstItemSize.toFloat()).coerceIn(0f, 1f)
dotIndicator.onPageScrolled(scrollState.firstVisibleItemIndex, offset)
}
}
}
For the integration I had to add XML file as well ->
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data />
<ru.tinkoff.scrollingpagerindicator.ScrollingPagerIndicator
android:id="#+id/dot_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:spi_dotColor="#color/ds_primary_25"
app:spi_dotSelectedColor="?attr/colorPrimary"
app:spi_dotSelectedSize="8dp"
app:spi_dotSize="8dp"
app:spi_dotSpacing="4dp" />
</layout>
And also adding this dependency to your Gradle file ->
api "androidx.compose.ui:ui-viewbinding:1.1.1"