Important! This week has been deliberately kept "light" in order to allow you to catch up. We will cover box layouts in Jetpack Compose, and one or two outstanding general Kotlin topics.
Imagine you want to display a map above a row containing text fields and a button to set the location. If you think about it, this isn't so easy. If you specify the modifier for the map to be Modifier.fillMaxSize()
, it will occupy the whole of the screen and there will be no room for the row. If you add the row first, it will appear on top of the screen.
What we need is another type of layout which allows us to specify exactly where each composable will be positioned, so we can position a composable at the top, the bottom, the left or the right. The Box
composable allows us to do this.
In a Box
, we can specify the alignment of the composables within the box. Here is an example:
Box(Modifier.fillMaxSize()) { Button( onClick = { }, modifier = Modifier.align(Alignment.BottomStart) ) { Text("Bottom Start") } Button( onClick = { }, modifier = Modifier.align(Alignment.BottomCenter) ) { Text("Bottom") } Button( onClick = { }, modifier = Modifier.align(Alignment.BottomEnd) ) { Text("Bottom End") } Button( onClick = { }, modifier = Modifier.align(Alignment.CenterStart) ) { Text("Centre Start") } Button( onClick = { }, modifier = Modifier.align(Alignment.Center) ) { Text("Centre") } Button( onClick = { }, modifier = Modifier.align(Alignment.CenterEnd) ) { Text("Centre End") } Button( onClick = { }, modifier = Modifier.align(Alignment.TopStart) ) { Text("Top Start") } Button( onClick = { }, modifier = Modifier.align(Alignment.TopCenter) ) { Text("Top") } Button( onClick = { }, modifier = Modifier.align(Alignment.TopEnd) ) { Text("Top End") } }
This layout would look something like this:
Note how we use the align()
method of our Modifier
for each button, to specify where it is aligned within the box. The possible values include:
TopStart
- the top left of the box (but top right in left-to-right languages, such as Arabic);TopCenter
- the centre of the top of the box;TopEnd
- the top right of the box (but top left in left-to-right languages);CenterStart
- the centre left of the box (but centre right in left-to-right languages);Center
- the centre of the box;CenterEnd
- the centreright of the box (but centre left in left-to-right languages);BottomStart
- the bottom left of the box (but bottom right in left-to-right languages);BottomCenter
- the centre of the bottom of the box;BottomEnd
- the bottom right of the box (but bottom left in left-to-right languages).The BoxWithConstraints
is an enhanced Box
which allows us to obtain the Box
's width and height from within it. Why is this useful? It makes it easier to create layouts which adapt to the screen size. For example, in our mapping application we might want to set the height of the row containing the text fields and buttons to 80dp. How big must the map be in this case? You want it to occupy the remainder of the screen. To calculate this, you subtract the height of the row (80dp) from the total height. Inside a BoxWithConstraints
, you can use:
this.maxWidth
to obtain the total width;this.maxHeight
to obtain the total height.dp
so you can subtract the height of the controls row, also in dp
, to obtain a height in dp
for the map. You can then call Modifier.height()
to set the height of the map to this calculated height.
We will use this opportunity to introduce one or two further general Kotlin features which you may find useful.
Kotlin features a more concise and flexible approach to getters and setters compared to Java. Rather than using explicit getter and setter methods, Kotlin's approach is to define properties (attributes) and then define how each property can be read and written.
For example, imagine a Time class in which you wanted to
ensure that incorrect times could not be set. What you could do is define two properties hours
and minutes
, but write custom setters which prevent the hours and minutes being set to incorrect values.
write access:
class Time { var hours: Int = 0 set(newHours: Int) { if(newHours in 0..23) { field = newHours } } var minutes: Int = 0 set(newMinutes: Int) { if(newMinutes in 0..59) { field = newMinutes } } override fun toString(): String { return "$hours:$minutes" } } fun main() { val t = Time() t.hours = 23 t.minutes = 48 println(t) t.hours = 24 println(t) t.minutes = 60 println(t) }Note how we declare two properties,
hours
and minutes
and give each a default value of 0. However, note how we then define a set()
method, which is indented one level from the property. This is a custom setter, which controls how the property can be updated. Each custom setter takes a parameter representing the new value, and then checks it to ensure that it's within the correct range. If it is, we set the underlying field (the actual value of the property) to the new value. Note that field
is a keyword in Kotlin, which represents the underlying value of a property.
The main()
demonstrates the use of the custom setters. We create a Time
object then set its hours and minutes to a sensible value (23 hours and 48 minutes). We then try to set the hours to 24 (invalid) and print the time object, and set the minutes to 60 (invalid) an print the time object again. The output will be as follows:
23:48 23:48 23:48So you can see that the custom setters, with validation, have prevented the
Time
object being set to an invalid time.
In some circumstances we might want to prevent a property from being updated from outside the class. An example might be a student ID in a Student
class, as this should never change. We could just make it a private
property, but the outside world would then not be able to read it. What we want is for the outside world to be able to read the student ID, but not change it. We can use a private setter for this:
class Student(studentIdIn: String, var name: String, var course: String) { var id = studentIdIn private set override fun toString(): String { return "$studentId: $name, studying $course" } } fun main() { var student = Student("1smitj01", "James Smith", "Web Design") student.course = "Software Engineering" // OK println(student.id) // OK - can read student ID from outside student.id="2smitj02" // cannot modify ID from outside due to private setter }
You will have seen this syntax when declaring state variables in Compose:
var name: String by remember { mutableStateOf("") }What exactly does the
by remember
mean? It's a Kotlin feature known as delegated properties. Delegated properties, like custom getters and setters, allow you to customise what happens when you retrieve or update a property (attribute) of an object. They do it with a delegate class; see the documentation for details. So delegates are rather like custom getters and setters, but rather than simply customising the getter and setter, you delegate the job of getting and setting a property to an entirely separate class - the delegate - which can give more flexibility in some situations. The by
keyword specifies that we will be using a delegate. In the case of state variables, remember
is the delegate.
Here is an example:
package com.example.delegates1 import kotlin.reflect.KProperty class Student (var name: String, var course: String) { var mark: Int by MarkDelegate() override fun toString(): String { return "Student $name on $course with mark $mark" } } class MarkDelegate(private var controlledProperty: Int = 0) { operator fun getValue(thisRef: Any?, property: KProperty<*>) : Int{ return controlledProperty } operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: Int) { if(newValue in 0..100) { controlledProperty = newValue } else { println("Cannot set ${property.name} to $newValue for $thisRef") } } } fun main (args: ArrayNote how we are declaring a property) { val john = Student("John", "Software Engineering") john.mark = 101 println(john) john.mark = 83 println(john) john.mark = -1 println(john) }
mark
inside the Student
class but declaring it with by MarkDelegate()
. This means that the MarkDelegate
class will be responsible for handling retrieving the value of, and setting the value of, the mark
property. MarkDelegate
is thus a delegate. A delegate should contain two methods, getValue()
and setValue()
, which retrieve and update the associated value. Note also how the delegate class takes the property that it's controlling as a parameter:
class Mark(private var controlledProperty: Int=0)If you look at
getValue()
, it's just returning the mark, so no custom behaviour occurs if we simply retrieve the value (e.g. by printing it). However setValue()
is more interesting. Note how it take the new value as a parameter (newValue
). The setValue()
checks that the new value is in the range 0 to 100, and only updates the mark to the value if it is. So in other words, marks below 0 or above 100 will be rejected. So if we try to set the mark to any value less than 0 or greater than 100, it will not be updated. Note that the thisRef
parameter in setValue()
and getValue()
refers to the object that the delegated property belongs to. Also the property
parameter represents the property being controlled, and the property's name can be retrieved with property.name
. Therefore the output of this program will be as follows:
Cannot set mark to 101 for Student John on course Software Engineering with mark 0 Student John on course Software Engineering with mark 0 Student John on course Software Engineering with mark 83 Cannot set mark to -1 for Student John on course Software Engineering with mark 83 Student John on course Software Engineering with mark 83When we try to set the mark to 101 or -1, it's rejected. However when we set the mark to 83, it's accepted.
Note that you can pass additional parameters to the delegate, but the first parameter always reference the property that the delegate is controlling.