Blog Infos
Author
Published
Topics
Published
Topics

Learn to create custom shapes and handle states in Jetpack Compose

This is part of a multi-part series about learning to use Jetpack Compose through code. This part of the series will be focusing on building the game details screen and also covering the test cases for this screen.

Learn with code: Jetpack Compose — Lists and Pagination (Part 1)

Note: All the images and game data in the article are taken from the awesome RAWG API.

Game details screen

 

There are a few components here and I have highlighted only the main ones in the above image. Let’s see what each of these components are from top to bottom: Game image, play button, game title, game genre, then below that on the same row you have released info and rating info, game description, show more/less toggle, platforms, stores, game developer and finally the game publisher.

Phew! Quite a few components one below the other! By the time we finish designing this screen you will see how easy and fast we can do this. Let’s code!

As you would have guessed by now, we can use a Column to design this layout as all the components are one below the other.

Column is a layout composable that places its children in a vertical sequence.

@Composable
fun GameDetails(
gameDetails: GameDetailsEntity,
openGameTrailer: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
// Game image
GameImage(image = gameDetails.backgroundImage)
// Play button
PlayTrailer(openGameTrailer = openGameTrailer)
// Title
Text(
modifier =
Modifier.padding(
start = 16.dp,
top = 30.dp,
end = 16.dp
),
text = gameDetails.name,
color = Color.Black,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
// Genres
Text(
modifier = Modifier.getDetailsModifier(),
text = gameDetails.genresEntity.toGenres()
)
// Released and rating info
ReleaseRating()
// About
Text(
modifier =
Modifier.padding(
start = 16.dp,
top = 16.dp,
end = 16.dp
),
text = "About",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
//Description with show more/less toggle
GameDescription(gameDetails.description)
// Platforms info
Text(
modifier = Modifier.getDetailsModifier(),
text = "Platforms",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
Text(
modifier = Modifier.getDetailsModifier(),
text = gameDetails.platformsEntity.toPlatforms()
)
// Stores
Text(
modifier = Modifier.getDetailsModifier(),
text = "Stores",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
Text(
modifier = Modifier.getDetailsModifier(),
text = gameDetails.storesEntity.toStores()
)
// Developer
Text(
modifier = Modifier.getDetailsModifier(),
text = "Developer",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
Text(
modifier = Modifier.getDetailsModifier(),
text =
gameDetails.developersEntity.toDevelopers()
)
// Publisher
Text(
modifier = Modifier.getDetailsModifier(),
text = "Publisher",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
Text(
modifier =
Modifier.padding(
start = 16.dp,
top = 8.dp,
end = 16.dp,
bottom = 16.dp
),
text =
gameDetails.publishersEntity.toPublishers()
)
}
}
fun Modifier.getDetailsModifier(): Modifier =
this.padding(start = 16.dp, top = 8.dp, end = 16.dp)

Initial details screen

 

Running the app now will give you the following screen:

Initial game details screen

 

The one thing you will notice right away is that your screen is not scrollable! To enable scrolling we can make use of verticalScroll modifier.

The verticalScroll and horizontalScroll modifiers provide the simplest way to allow the user to scroll an element when the bounds of its contents are larger than its maximum size constraints.

Let’s go ahead and add it to our Column.

@Composable
fun GameDetails(
gameDetails: GameDetailsEntity,
openGameTrailer: () -> Unit
) {
val scrollState = rememberScrollState()
Column(
modifier =
Modifier.fillMaxSize()
.verticalScroll(scrollState)
) {
// column children as above code snippet
}
}

Scroll behaviour to game details screen

 

Game details screen with scroll

 

As promised, it hardly took us a few minutes to build this! Now, onto the interesting parts of the design!

The play button is overlapping the game image. First, let’s style our play button.

@Composable
fun PlayTrailer(
modifier: Modifier = Modifier,
openGameTrailer: () -> Unit
) {
Box(modifier = modifier) {
IconButton(onClick = openGameTrailer) {
Image(
modifier =
Modifier.width(50.dp)
.height(50.dp)
.align(Alignment.Center)
.graphicsLayer {
shadowElevation = 20.dp.toPx()
shape =
RoundedCornerShape(15.dp)
clip = true
}
.background(Color(0xFFF50057)),
painter =
painterResource(
id = R.drawable.ic_play
),
contentDescription = "Play Trailer"
)
}
}
}
view raw PlayTrailer.kt hosted with ❤ by GitHub

Styled play button

 

Now, let’s align the play button to the bottom of our game image. To do this, we can simply wrap our GameImage and PlayTrailer inside a ConstraintLayout and provide the necessary constraints.

@Composable
fun GameDetails(
gameDetails: GameDetailsEntity,
openGameTrailer: () -> Unit
) {
val scrollState = rememberScrollState()
Column(
modifier =
Modifier.fillMaxSize()
.verticalScroll(scrollState)
) {
ConstraintLayout {
val (play, gameImage) = createRefs()
// Game image
GameImage(
image = gameDetails.backgroundImage,
modifier =
Modifier.constrainAs(gameImage) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
)
// Play button
PlayTrailer(
openGameTrailer = openGameTrailer,
modifier =
Modifier.constrainAs(play) {
top.linkTo(gameImage.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
bottom.linkTo(gameImage.bottom)
}
)
}
// remaining children same as before
}
}

Let’s preview this and see what we got so far.

Game image and play button

Job Offers

Job Offers


    Android Developer

    Small and Modern GmbH
    Hamburg, Remote (Germany)
    • Full Time
    apply now

    Mobile Developer Android (m/w/d) Firebase

    REWE digital
    Köln / Cologne
    • Full Time
    apply now

    Senior Android Developer

    Komoot
    remote
    • Full Time
    apply now
Load more listings

OUR VIDEO RECOMMENDATION

, ,

The Evolution of Android Graphics in Android 12/13

Android 12 and 13 both added significant new capabilities to Android platform graphics, including RenderEffect, RuntimeShader, and more. At the same time, RenderScript has been deprecated and we’ve introduced the RenderScript Intrinsics Replacement Toolkit. This…
Watch Video

The Evolution of Android Graphics in Android 12/13

Daniel Galpin
Android Developer Advocate and Fast Talking YouTuber
Google

The Evolution of Android Graphics in Android 12/13

Daniel Galpin
Android Developer Ad ...
Google

The Evolution of Android Graphics in Android 12/13

Daniel Galpin
Android Developer Advocat ...
Google

Jobs

We have almost got our desired result but the GameImage is missing something.

In traditional XML layouts you could shape your views using a custom drawable or you could use a ShapeableImageView for shaping your images.

Compose already provides some basic shapes like RoundedCornerShapeCircleShapeCutCornerShape but if you want to draw your own custom shape you can easily do it by extending the Shape interface. Here, you will have to override the createOutline() method which expects you to return anOutline.

If you look at the code, Outline is a sealed class which has 3 subclasses:

  • Rectangle — used for a rectangular area
  • Rounded — used for rectangular area with rounded corners
  • Generic — this let’s you define your own Path

As you would have guessed, we will using the generic shape and providing it with our custom path.

class BottomRoundedArcShape : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Generic(
path = drawArcPath(size = size)
)
}
}

Custom shape in compose

 

Let’s now create our custom path by implementing the drawArchPath() method. Our image needs a rounded bottom shape. You can visualise the path as follows:

Custom shape path

fun drawArcPath(size: Size): Path {
return Path().apply {
reset()
// go from (0,0) to (width, 0)
lineTo(size.width, 0f)
// go from (width, 0) to (width, height)
lineTo(size.width, size.height)
// Draw an arch from (width, height) to (0, height)
// starting from 0 degree to 180 degree
arcTo(
rect =
Rect(
Offset(0f, 0f),
Size(size.width, size.height)
),
startAngleDegrees = 0f,
sweepAngleDegrees = 180f,
forceMoveTo = false
)
// go from (0, height) to (0, 0)
lineTo(0f, 0f)
close()
}
}
view raw CustomPath.kt hosted with ❤ by GitHub

Custom path

 

Let’s add the shape we just created to our GameImage.

@Composable
fun GameImage(
image: String,
modifier: Modifier = Modifier
) {
Image(
modifier =
modifier
.fillMaxWidth()
.height(300.dp)
.graphicsLayer {
clip = true
shape = BottomRoundedArcShape()
shadowElevation = 50.dp.toPx()
},
contentScale = ContentScale.Crop,
painter =
rememberImagePainter(
data = image,
builder = {
placeholder(R.drawable.app_logo)
crossfade(true)
}
),
contentDescription = "Game Image"
)
}
view raw GameImage.kt hosted with ❤ by GitHub

GameImage.kt

 

Let’s preview our GameImage and PlayTrailer now and see what we get.

Final game image and play button

Looks pretty good! I will leave designing the release and rating info to you. Let me know about your design in the comments!

As you can see so far, the game description is pretty long and not everyone will be interested in reading it all. Let’s create a show more/less toggle to handle this use case.

One way we can handle this show more/less use case is by simply changing the maxLines attribute of your Text composable. To do this, we can basically make use of States to tell our composable to recompose whenever the maxLines value changes.

One more thing to consider here is that, we need to display Show More only if the game description overflows maxLines. To do this, we can make use of onTextLayout callback. Let’s code!

State in an app is any value that can change over time.

@Composable
fun GameDescription(description: String) {
val maxLines = remember { mutableStateOf(4) }
val toggle = remember {
mutableStateOf(DescriptionStatus.DEFAULT)
}
Column {
// Desctiption text
Text(
modifier =
Modifier.getDetailsModifier()
.testTag("Description"),
text = description,
overflow = TextOverflow.Ellipsis,
maxLines = maxLines.value,
onTextLayout = {
if (it.lineCount == 4 &&
it.isLineEllipsized(3)
) {
// game description has overflowed maxLines
// show Show More
toggle.value =
DescriptionStatus.SHOW_MORE
} else if (it.lineCount > 4) {
// showing entire description
// show Show Less
toggle.value =
DescriptionStatus.SHOW_LESS
} else {
// game description has not overflowed maxLines
// do not show Show More at all
}
}
)
when (toggle.value) {
DescriptionStatus.SHOW_MORE -> {
// display show more
Text(
modifier =
Modifier.padding(
start = 16.dp,
end = 16.dp
)
.clickable {
maxLines.value =
Int.MAX_VALUE
},
text = "Show More",
color = Color(0xFFF50057),
textDecoration =
TextDecoration.Underline,
)
}
DescriptionStatus.SHOW_LESS -> {
// display show less
Text(
modifier =
Modifier.padding(
start = 16.dp,
end = 16.dp
)
.clickable {
maxLines.value = 4
},
text = "Show Less",
color = Color(0xFFF50057),
textDecoration =
TextDecoration.Underline,
)
}
else -> {
// show more is not displayed at all
// do not do anything
}
}
}
}
enum class DescriptionStatus {
DEFAULT,
SHOW_MORE,
SHOW_LESS
}

Game description and toggle

 

Let’s run the app now and see what we get.

 

Game details screen

 

Looks pretty good! Again, I am leaving the designing of release and rating info to you.

Now that we have our game details screen ready, let’s go ahead and write a test for it.

class GameDetailsTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun game_details_should_be_shown() {
composeTestRule.setContent {
EpicWorldTheme {
GameDetails(
gameDetails =
FakeGamesData.getFakeGameDetails(),
openGameTrailer = {}
)
}
}
// Game image should be shown
composeTestRule
.onNodeWithContentDescription("Game Image")
.assertIsDisplayed()
// Trailer play button should be shown and clickable
composeTestRule
.onNodeWithContentDescription("Play Trailer")
.assertIsDisplayed()
.assertHasClickAction()
// Game title should be shown
composeTestRule
.onNodeWithText("Grand Theft Auto V")
.assertIsDisplayed()
// Game genres should be shown
composeTestRule
.onNodeWithText("Action Adventure")
.assertIsDisplayed()
// Game description should be shown
composeTestRule
.onNodeWithText("About")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Show More")
.assertIsDisplayed()
composeTestRule
.onNodeWithTag("Description")
.assertIsDisplayed()
// Game platforms should be shown
composeTestRule
.onNodeWithText("Platforms")
.assertIsDisplayed()
composeTestRule
.onNodeWithTag("Game Platforms")
.assertIsDisplayed()
// Scroll to desired view
composeTestRule
.onNodeWithText("Stores")
.performScrollTo()
composeTestRule
.onNodeWithTag("Game Stores")
.performScrollTo()
// Game stores should be shown
composeTestRule
.onNodeWithText("Stores")
.assertIsDisplayed()
composeTestRule
.onNodeWithTag("Game Stores")
.assertIsDisplayed()
// Scroll to desired view
composeTestRule
.onNodeWithText("Developer")
.performScrollTo()
composeTestRule
.onNodeWithText("Rockstar North")
.performScrollTo()
// Game developers should be shown
composeTestRule
.onNodeWithText("Developer")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Rockstar North")
.assertIsDisplayed()
// Scroll to desired view
composeTestRule
.onNodeWithText("Publisher")
.performScrollTo()
composeTestRule
.onNodeWithText("Rockstar Games")
.performScrollTo()
// Game publishers should be shown
composeTestRule
.onNodeWithText("Publisher")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Rockstar Games")
.assertIsDisplayed()
}
}
object FakeGamesData {
fun getFakeGameDetails(): GameDetailsEntity {
// provide fake game details data
}
}

Game details test

 

The test is pretty self-explanatory. We are passing in fake GameDetailsEntity to our composable and then asserting that all the views are displayed.

One thing to note here is that, if your view is off-screen and you are trying to assert that the view is displayed then your test case will fail. For this, you can first scroll to your view using performScroll() before making your assertion.

I will leave the rest of the test cases to you. Use all your creativity and make sure you cover as many test cases as possible for this screen!

You can find the complete source code with all the tests for the details screen in this repository.

In this post we have designed our game details screen and also tested the same. In the next post, let’s explore and build the game videos screen and also the different UI tests for this screen. See you there:

Thanks for reading! If you liked the article please do leave a clap 👏 and don’t forget to subscribe and follow to get regular updates! 🙂 You can also connect with me on LinkedIn. I would love to see your designs and tests!

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
Nowadays authentication has become common in almost all apps. And many of us know…
READ MORE
blog
Collections are a set of interfaces and classes that implement highly optimised data structures.…
READ MORE

Leave a Reply

Your email address will not be published.

Fill out this field
Fill out this field
Please enter a valid email address.

Menu