Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Compose/basic/codelab #14

Merged
merged 33 commits into from
Jan 12, 2025
Merged

Compose/basic/codelab #14

merged 33 commits into from
Jan 12, 2025

Conversation

sh1mj1
Copy link
Owner

@sh1mj1 sh1mj1 commented Jan 12, 2025

study compose basic codelab

class BasicComposeActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            LearningTestTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background,
                ) {
                    Greeting("Android")
                }
            }
        }
    }
}

You use setContent to define your layout,
but instead of using an XML file as you'd do in the traditional View system(like
setContentView),
you call Composable functions within it.

LearningTestTheme is a way to style Composable functions.(Theme.kt)

Surface

You can set a different background color for the Greeting by wrapping the Text composable with a
Surface.
Surface takes a color, so use MaterialTheme.colorScheme.primary

The components nested inside Surface will be drawn on top of that background color.
GreetingV2.kt: GreetingV2 use Surface.

Look at the text color.
The text is now white even we didn't define it.

The Material Components such as androidx.compose.material3.Surface, are built to make your
experience better
by taking taking care of common features that you probably want in your app,
such as choosing an appropriate color for text.

We say Material is opinionated because it provides good defaults and patterns that are common to
most apps.
The Material components in Compose are built ont top of other foundational components (in
androidx.compose.foundation),
which are also accessible from your app components in case you need more flexibility.

In this case, Surface understands that, when the background is set to the primary color,
any text on top of it should use the onPrimary color, which is also defined in the theme.

Modifier

Most Compose Ui elements such as Surface and Text accept an optional modifier parameter.
Modifiers tell a UI element how to lay out, display, or behave within its parent layout.

For example, the padding modifier will apply an amount of space around the element it decorates.
You can create a padding modifier with Modifier.padding().
You can also add multiple modifiers by chaining them,
so in our case we can add the padding modifier to the default one: modifier.padding(24.dp).

Now, add padding to your Text on the screen: GreetingV3.kt: GreetingV3 use
Modifier.padding() and Surface.

Modifiers allow you to decorate or augment a composable. Modifiers let you do these sorts of things:

  • Change the composable's size, layout, behavior, and appearance
  • Add information, like accessibility labels
  • Process user input
  • Add high-level interactions, like making an element clickable, scrollable, draggable, or zoomable

Reusing composables

By making small reusable components it's easy to build up a library of UI elements used in your
app.
Each one is responsible for one small part of the screen and can be edited independently.

As a best practice, ur function should include a Modifier parameter that is assigned an empty
Modifier by default.
Forward this modifier to the first composable you call inside your function.
This way, the calling site can adapt instructions and behaviors from outside of your composable
function.

Create a Composable called MyApp that includes the greeting.

BasicComposeActivity.kt
and GreetingV3Preview.kt reuse MyApp composable function.

Columns and Rows

The three basic standard layout elements in Compose are Column, Row and Box.
img.png

They are Composable functions that take Composable content, so you can place items inside.
For example, each child inside of a Column will be placed vertically.

GreetingV4.kt: use Column.

Composable functions can be used like any other functions in Kotlin.
This makes building UIs powerful since you can add statements to influence how the UI will be
displayed.
For example, you can use a for loop to add elements to the
Column: BasicComposeActivity.kt: just like MyApp2

Column's children Test:
use onParent.
if it has only one composable layout, you can use Modifier.testTag(...).

https://developer.android.com/develop/ui/compose/modifiers

Add ElevatedButton

GreetingV5.kt
The Column is part of a Row, which contains:

Effect of weight(1f) on the Column:

  • The Column is instructed to take up all remaining horizontal space in thr Row after the
    ElevatedButton is measured and laid out.
  • Since the ElevatedButton doesn't have a weight, it only takes up as much space as it needs to
    display its content (Text("Show more")).

The Column expands to fill all available space not occupied by the ElevatedButton.
There's no alignEnd modifier so, instead, you give some weight to the composable at the start.

I wrote a test code for GreetingV5.kt like below.

composeTestRule.onRoot()
    .onChild() // Surface
    .onChildren() // Row
    .assertCountEquals(1)

But it failed.

Reason: Expected '2' nodes but found '3' nodes that satisfy: ((((isRoot).children)[0]).children)
Nodes found:
1) Node #5 at (l=84.0, t=137.0, r=168.0, b=180.0)px
Text = '[Hello]'
Actions = [SetTextSubstitution, ShowTextSubstitution, ClearTextSubstitution, GetTextLayoutResult]
Has 2 siblings
2) Node #6 at (l=84.0, t=180.0, r=209.0, b=223.0)px
Text = '[Android]'
Actions = [SetTextSubstitution, ShowTextSubstitution, ClearTextSubstitution, GetTextLayoutResult]
Has 2 siblings
3) Node #7 at (l=684.0, t=148.0, r=996.0, b=253.0)px
Focused = 'false'
Role = 'Button'
Text = '[Show more]'
Actions = [OnClick, RequestFocus, SetTextSubstitution, ShowTextSubstitution, ClearTextSubstitution, GetTextLayoutResult]
MergeDescendants = 'true'

I expected that the children of the Row are Column and ElevatedButton.
But actually, the assertCountEquals counts all children of the Column.

I think that the layout of Row and Column is merged in the actual Compose tree, not nested as in the
Kotlin Composable code.

Basically, layout containers like Row and Column only play a "visual arrangement" role, and if they
do not have separate semantic information, they can be omitted (or merged with other nodes) in the
merged tree.
In other words, if Row itself is to be held as a separate semantic node, the following actions are
required.

  • Give explicit semantics to Row using Modifier.semantics { ... } or
  • Mark it with a test tag like Modifier.testTag("RowTag") to indicate that "this node is a node that
    is needed separately in the test" or
  • Row is exposed as a semantic node only when it requires something like accessibility (tab
    movement).

That is, if Row or Column is to be displayed separately, you must provide information that says "
Include this Row in the semantic tree" directly.

GreetingV5WithTestTag or GreetingV5WithSemantic function has a test tag for the
Row and Column.
Now i can test the Row and Column separately.

So, this is it?
It is not very good to add The code only for the test code.
How did we test the traditional View system using xml?
There are several ways to manage these identifiers, but it was generally considered easiest to get
them through ID (view identifier).
reference: https://developer.android.com/training/testing/espresso/basics#finding-view

onView(allOf(withId(R.id.my_view), withText(“ Hello !“)))

Setting an identifier in the View system was not awkward because it was used in various situations,
but Compose is designed declaratively, so the need to create identifiers is less felt, so it may be
natural to experience this inconvenience.

State in Compose

ElevatedButton has content parameter as composable trailing lambda.

var expanded: Boolean = false
ElevatedButton(
    onClick = { expanded = !expanded }
) {
    Text(if (expanded) "Show less" else "Show more")
}

This doesn't work as expected.
Setting a different value for the expanded variable won't make Compose detect it as a state change
so nothing will happen.

Compose apps transform data into UI by calling composable functions.
If your data changes, Compose re-executes these functions with the new data, creating an updated
UI—this is called recomposition.
Compose also looks at what data is needed by an individual composable so that it only needs to
recompose components whose data has changed and skip recomposing those that are not affected.

The reason why mutating this variable does not trigger recompositions is that it's not being tracked
by Compose. Also, each time Greeting is called, the variable will be reset to false.

To add internal state to a composable, you can use the mutableStateOf function, which makes Compose
recompose functions that read that State.
State and MutableState are interfaces that hold some value and trigger UI updates (recompositions)
whenever that value changes.

However you can't just assign mutableStateOf to a variable inside a composable.
As explained before, recomposition can happen at any time which would call the composable again,
resetting the state to a new mutable state with a value of false.

Composable functions can execute frequently and in any order,
you must not rely on the ordering in which the code is executed,
or on how many times this function will be recomposed.

To preserve state across recompositions, remember the mutable state using remember.

val expanded = remember { mutableStateOf(false) }

Note that if you call the same composable from different parts of the screen you will create
different UI elements,
each with its own version of the state.
You can think of internal state as a private variable in a class.

The composable function will automatically be "subscribed" to the state.
If the state changes, composables that read these fields will be recomposed to display the updates.

You don't need to remember extraPadding against recomposition because it's doing a simple
calculation.

State Hoisting

OnboardingScreenV1.kt
What if i wanna show OnBoardingScreen if shouldShowOnBoarding is true or Greeting if it's false?
You don't have access to the shouldShowOnBoarding variable in the parent composable.
You need to share the state between the two composables.
Instead, you can hoist the state up to the parent composable.

Let's see Greetings.kt, OnboardingScreenV2.kt.
We hoist the shouldShowOnBoarding state up to the parent composable, MyAppV4.
And we didn't hoist the expanded state up to the parent composable, Greetings.

Basic hoisting recommendation:
When the state is used together in multiple composables,
or when you need to modify, test, or control that state from the parent (or outside),
hoist that state up.

If you hoist more than you need, the parent code becomes more complex,
and it can be inconvenient when the child needs more than just "UI representation".

This looks like a difficult concept, but in fact, there is a similar concept
in simple classes that are not composable functions.
I think DI is a similar concept to this.
if you inject too many properties from the outside into a class,
you may encounter the class explosion problem.
So, when creating composable functions or classes,
I think it is important to inject only the necessary parts from the outside.

Performant lazy list

If you set the namee list like below,

Greetings(names = List(1000) { "$it" })

You can see that the UI is slow to load.
And You can not even see the end of the list.

Compose provides a LazyColumn and LazyRow composable that can display a large list of items
efficiently.
It is like the RecyclerView in the xml View system.

LazyColumn doesn't recycle its children like RecyclerView.
It emits new Composables as you scroll through it and is still performant,
as emitting Composables is relatively cheap compared to instantiating Android Views.

GreetingsV2.kt use LazyColumn instead of Column.
You can see that the UI is loaded quickly and you can see the end of the list.

Persisting state

Let's run the BasicComposeActivity.kt and click the "Continue" button.
You can see greetings in your screen.
Now, rotate the screen.
You can see that the greetings are gone and the "Continue" button is shown again.

This is because the state is not persisted across configuration changes.

@RunWith(AndroidJUnit4::class)
class BasicComposeActivityTest {
    @get:Rule
    val composeTestRule = createAndroidComposeRule(BasicComposeActivity::class.java)

    @Test
    fun not_save_the_state_after_configuration_change() {
        // given
        composeTestRule.onNodeWithText("Continue")
            .assertIsDisplayed()

        // when
        composeTestRule.onNodeWithText("Continue")
            .performClick()
        composeTestRule.onNodeWithText("Continue")
            .assertDoesNotExist()

        composeTestRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE

        // then
        assertThrows<AssertionError> {
            composeTestRule.onNodeWithText("Continue")
                .assertDoesNotExist()
        }
        composeTestRule.onNodeWithText("Continue")
            .assertExists()
    }
}

The remember function works only as long as the composable is kept in the Composition.
When you rotate, the whole activity is restarted so all state is lost.
This also happens with any configuration change and on process death.

Instead of using remember you can use rememberSaveable.
This will save each state surviving configuration changes (such as rotations) and process death.

Animating list

In Compose, there are multiple ways to animate your UI: from high-level APIs for simple animations
to low-level methods for full control and complex transitions. You can read about them in the
documentation.

The spring spec does not take any time-related parameters.
Instead it relies on physical properties (damping and stiffness) to make animations more natural.
It takes a target value and a spring spec, and returns an animated value that you can use in your
composable.

https://developer.android.com/codelabs/jetpack-compose-basics#5

- Material Components such as Surface are opinionated
@sh1mj1 sh1mj1 merged commit f803472 into dev Jan 12, 2025
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant