Week 2: Building App UI

Kotlin fundamentals

Conditionals

  • if statements:

    if (condition1) {
        // code to execute if condition1 is true
    } else if (condition2) {
        // code to execute if condition2 is false
    } else if (condition3) {
        // code to execute if condition3 is false
    } else {
        // code to execute if condition is false
    }
    
  • When there are more than 2 branches, use the when statement.

    ../_images/unit2-pathway1-activity2-section3-2f7c0a1e312a2581_14401.png
  • Examples:

    fun main() {
        val trafficLightColor = "Black"
    
        when (trafficLightColor) {
            "Red" -> println("Stop")
            "Yellow" -> println("Slow")
            "Green" -> println("Go")
            else -> println("Invalid traffic-light color")
        }
    }
    
    fun main() {
        val x = 3
    
        when (x) {
            2 -> println("x is a prime number between 1 and 10.")
            3 -> println("x is a prime number between 1 and 10.")
            5 -> println("x is a prime number between 1 and 10.")
            7 -> println("x is a prime number between 1 and 10.")
            else -> println("x isn't a prime number between 1 and 10.")
        }
    }
    
  • Use , to denote multiple conditions:

    ../_images/unit2-pathway1-activity2-section3-4e778c4c4c044e51_14401.png
  • Example:

    fun main() {
        val x = 3
    
        when (x) {
            2, 3, 5, 7 -> println("x is a prime number between 1 and 10.")
            else -> println("x isn't a prime number between 1 and 10.")
        }
    }
    
  • Use the in keyword and a range of values:

    ../_images/unit2-pathway1-activity2-section3-400f940f363bd3c4_14401.png
    fun main() {
        val x = 3
    
        when (x) {
            2, 3, 5, 7 -> println("x is a prime number between 1 and 10.")
            in 1..10 -> println("x is a number between 1 and 10, but not a prime number.")
            else -> println("x isn't a prime number between 1 and 10.")
        }
    }
    
  • Use the is keyword to check the data type:

    ../_images/unit2-pathway1-activity2-section3-66841365125b37aa_14401.png
    fun main() {
        val x: Any = 20
    
        when (x) {
            2, 3, 5, 7 -> println("x is a prime number between 1 and 10.")
            in 1..10 -> println("x is a number between 1 and 10, but not a prime number.")
            is Int -> println("x is an integer number, but not between 1 and 10.")
            else -> println("x isn't an integer number.")
        }
    }
    
  • You can also use conditionals as expressions to return different values for each branch of condition:

    ../_images/unit2-pathway1-activity2-section4-a6ff7ba09d3cdea3_14401.png
  • If the bodies only contain a return value or expression, you can remove the curly braces to make the code more concise.

  • Examples:

    fun main() {
        val trafficLightColor = "Black"
    
        val message =
            if (trafficLightColor == "Red") "Stop"
            else if (trafficLightColor == "Yellow") "Slow"
            else if (trafficLightColor == "Green") "Go"
            else "Invalid traffic-light color"
    
        println(message)
    }
    
    fun main() {
        val trafficLightColor = "Amber"
    
        val message = when(trafficLightColor) {
            "Red" -> "Stop"
            "Yellow", "Amber" -> "Slow"
            "Green" -> "Go"
            else -> "Invalid traffic-light color"
        }
        println(message)
    }
    
  • In most cases, a when expression requires the else branch because the when expression needs to return a value. The Kotlin compiler checks whether all the branches are exhaustive. An else branch ensures that there won’t be a scenario in which the variable doesn’t get assigned a value.

Nullability

  • Use null to indicate that there’s no value associated with a certain variable. Example:

    fun main() {
        val favoriteActor = null
    }
    
  • Nullable vs non-nullable types:

    • Nullable types are variables that can hold null.

    • Non-nullable types are variables that cannot hold null.

    ../_images/unit2-pathway1-activity3-section2-c3bbad8de6afdbe9_14401.png
  • For example, a String? type can hold either a string or null, whereas a String type can only hold a string. To declare a nullable variable, you need to explicitly add the nullable type. Without the nullable type, the Kotlin compiler infers that it’s a non-nullable type.

    fun main() {
        var number: Int? = 10
        println(number)
    
        number = null
        println(number)
    }
    

    Note

    While you should use nullable variables for variables that can carry null, you should use non-nullable variables for variables that can never carry null because the access of nullable variables requires more complex handling.

  • The code below produces an error message:

    fun main() {
        var favoriteActor: String? = "Sandra Solulu"
        println(favoriteActor.length)
    }
    
    ../_images/unit2-pathway1-activity3-section3-5c5e60b58c31d162_14401.png
  • This error is a compile error, or compile-time error. A compile error happens when Kotlin isn’t able to compile the code due to a syntax error in your code.

  • Kotlin achieves null safety, which refers to a guarantee that no accidental calls are made on potentially null variables. This doesn’t mean that variables can’t be null. It means that if a member of a variable is accessed, the variable itself can’t be null.

  • This is critical because if there’s an attempt to access a member of a variable that’s null - known as a null reference - the app crashes. This type of crash is known as a runtime error, in which the error happens after the code has been compiled, and runs.

  • Due to null safety, such runtime errors are prevented because the Kotlin compiler forces a null check for nullable types. A null check refers to a process of checking whether a variable could be null, before it’s accessed and treated as a non-nullable type.

  • There are various operators used to work with nullable variables.

  • The ?. safe call operator is used to access methods or properties of nullable variables.

    ../_images/unit2-pathway1-activity3-section3-a09b732437a133aa_14401.png
  • The ?. safe call operator allows safer access to nullable variables because the Kotlin compiler stops any attempt of member access to null references and returns null for the member accessed. Example:

    println(favoriteActor?.length)
    
  • When the below code is run, the output is null. The program doesn’t crash despite an attempt to access the length property of a null variable. The safe call expression simply returns null.

    fun main() {
        var favoriteActor: String? = null
        println(favoriteActor?.length)
    }
    

    Note

    You can also use the ?. safe call operators on non-nullable variables to access a method or property. While the Kotlin compiler won’t give any error for this, it’s unnecessary because the access of methods or properties for non-nullable variables is always safe.

  • The !! not-null assertion operator is used to access methods or properties of nullable variables.

    ../_images/unit2-pathway1-activity3-section3-1a6f269bfd700839_14401.png
  • If you use the !! not-null assertion, it means that you assert that the value of the variable isn’t null, regardless of whether it is or isn’t. You’re telling the compiler, I’m very very very sure that this value isn’t null, don’t complain about it during compile time.

  • Unlike ?. safe-call operators, the use of a !! not-null assertion operator may result in a NullPointerException error being thrown if the nullable variable is indeed null. Thus, it should be done only when the variable is always non-nullable or proper exception handling is set in place.

  • Example:

    fun main() {
        var favoriteActor: String? = "Sandra Solulu"
        println(favoriteActor!!.length)
    }
    
  • In the above code, if the !! is removed, the compiler complains:

    Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'kotlin.String?'.
    
  • By adding !!, you’re telling the compiler that the value will never be null, and the compiler won’t complain during compile-time. However, if you were wrong and the value is indeed null, a NullPointerException error is thrown during run-time.

  • Example of code that will not cause any compile-time error, but will cause a NullPointerException run-time error:

    fun main() {
        var favoriteActor: String? = null
        println(favoriteActor!!.length)
    }
    
  • To perform null checks, you can check that the nullable variable isn’t equal to null with the != comparison operator

    ../_images/unit2-pathway1-activity3-section3-326d68521327f229_14401.png
  • You can also check if a variable is null using ==

  • To use if/else together with a null check:

    ../_images/unit2-pathway1-activity3-section3-4cca066cf405b09c_14401.png
  • The null check and if condition are suitable when there are multiple lines of code that use the nullable variable. Example:

    fun main() {
        var favoriteActor: String? = "Sandra Solulu"
    
        if (favoriteActor != null) {
            println("Your favorite actor's name is ${favoriteActor}.")
            println("The number of characters in your favorite actor's name is ${favoriteActor.length}.")
        } else {
            println("You didn't input a name.")
        }
    }
    
  • The null check and if/else are suitable to convert a nullable variable to a non-nullable variable.

    ../_images/unit2-pathway1-activity3-section3-8bbf0c7e50163906_14401.png
  • Example:

    fun main() {
        var favoriteActor: String? = "Sandra Solulu"
    
        val lengthOfName = if (favoriteActor != null) {
            favoriteActor.length
        } else {
            0
        }
    
        println("The number of characters in your favorite actor's name is $lengthOfName.")
    }
    
  • The ?: Elvis operator can be used together with the ?. safe-call operator. It adds a default value when the ?. safe-call operator returns null.

    ../_images/unit2-pathway1-activity3-section3-85be2b9161680ecf_14401.png
  • If the variable isn’t null, the expression before the ?: Elvis operator executes. If the variable is null, the expression after the ?: Elvis operator executes.

  • Example:

    fun main() {
        var favoriteActor: String? = "Sandra Solulu"
    
        val lengthOfName = favoriteActor?.length ?: 0
    
        println("The number of characters in your favorite actor's name is $lengthOfName.")
    }
    

Classes and Objects

  • Classes provide blueprints from which objects can be constructed. An object is an instance of a class that consists of data specific to that object. The terms object and class instance are interchangable.

  • As an analogy, imagine that you build a house. A class is similar to an architect’s design plan, also known as a blueprint. The blueprint isn’t the house; it’s the instruction for how to build the house. The house is the actual thing, or object, which is built based on the blueprint.

  • Just like the house blueprint specifies multiple rooms and each room has its own design and purpose, each class has its own design and purpose. To know how to design your classes, you need to get familiar with object-oriented programming (OOP), a framework that teaches you to enclose data, logic, and behavior in objects.

  • OOP helps you simplify complex, real-world problems into smaller objects. There are four basic concepts of OOP:

    • Encapsulation. Wraps related properties, and methods that perform actions on those properties, in a class. For example, a phone encapsulates a camera, display, memory cards, and several other hardware and software components. You don’t have to worry about how components are wired internally.

    • Abstraction. An extension to encapsulation. The idea is to hide the internal implementation logic as much as possible. For example, to take a photo with your mobile phone, all you need to do is open the camera app, point your phone to the scene that you want to capture, and click a button to capture the photo. You don’t need to know how the camera app is built or how the camera hardware on your mobile phone actually works. In short, the internal mechanics of the camera app and how a mobile camera captures the photos are abstracted to let you perform the tasks that matter.

    • Inheritance. Enables you to build a class upon the characteristics and behavior of other classes by establishing a parent-child relationship. For example, there are different manufacturers who produce a variety of mobile devices that run Android OS, but the UI for each of the devices is different. In other words, the manufacturers inherit the Android OS feature and build their customizations on top of it.

    • Polymorphism. The word is an adaptation of the Greek root poly-, which means many, and -morphism, which means forms. Polymorphism is the ability to use different objects in a single, common way. For example, when you connect a Bluetooth speaker to your mobile phone, the phone only needs to know that there’s a device that can play audio over Bluetooth. However, there are a variety of Bluetooth speakers that you can choose from and your phone doesn’t need to know how to work with each of them specifically.

Smart Home App

  • To learn about classes and objects, we’ll build a smart home app that simulates the behavior of smart devices. The app consists of a class structure that represents smart devices, such as a smart TV and a smart light.

  • The code that we write won’t interact with real hardware devices. Instead, we’ll print actions in the console using println() to simulate the interactions.

Define a class

  • When you define a class, you specify the properties and methods that all objects of that class should have.

  • To define a class:

    ../_images/unit2-pathway1-activity4-section2-9a07f83c06449f38_14401.png
  • The part before the opening curly brace is known as the class header.

  • Class naming conventions:

    • Don’t use Kotlin keywords (like fun, etc) as a class name

    • Use PascalCase: each word begins with a capital letter, no spaces.

  • A class consists of three major parts:

    • Properties: variables that specify the attributes of the class’s objects.

    • Methods: functions that contain the class’s behaviors and actions.

    • Constructors: a special function that creates instances of the class throughout the program in which it’s defined.

  • Data types like Int, Float, String, and Double are classes. For example, this code creates an object of the Int class:

    val number: Int = 1
    
  • Example of a bare basic class:

    class SmartDevice {
        // empty body
    }
    
    fun main() {
    }
    

Create a class instance

  • A class is a blueprint for an object. The Kotlin runtime uses the class to create an object of that particular type. The SmartDevice class is a blueprint of what a smart device is. To have an actual smart device in your program, you need to create a SmartDevice object instance. Creating an object instance is known as instantiation.

  • The instantiation syntax:

    ../_images/unit2-pathway1-activity4-section3-1d25bc4f71c31fc9_14401.png
  • To create the object and assign it to a variable:

    ../_images/unit2-pathway1-activity4-section3-f58430542f2081a9_14401.png
  • When using the val keyword, the variable is immutable, but the object itself is mutable. This means that you can’t reassign another object to the variable, but you can change the object itself.

  • Example:

    val smartTvDevice = SmartDevice()
    
    // Cannot
    smartTvDevice = AnotherSmartDevice()
    
    // Can
    smartTvDevice.someProperty = "some value"
    

Define class methods

  • Class methods are actions that the class can perform. They are defined as functions in the class. For example, imagine that you own a smart device, a smart TV, or a smart light, which you can switch on and off with your mobile phone. The smart device is translated to the SmartDevice class in programming, and the action to switch it on and off is represented by the turnOn() and turnOff() functions, which enable the on and off behavior.

  • To define a function in a class, place the function in the class body. A function inside a class body is known as a member function or a method.

  • Example:

    class SmartDevice {
        fun turnOn() {
            println("Smart device is turned on.")
        }
    
        fun turnOff() {
            println("Smart device is turned off.")
        }
    }
    
  • So far, you defined a class that serves as a blueprint for a smart device, created an instance of the class, and assigned the instance to a variable. Now you use the SmartDevice class’s methods to turn the device on and off.

  • To call a class method from outside of the class:

    ../_images/unit2-pathway1-activity4-section4-fc609c15952551ce_14401.png
  • Example:

    fun main() {
        val smartTvDevice = SmartDevice()
        smartTvDevice.turnOn()
        smartTvDevice.turnOff()
    }
    

Define class properties

  • Class methods define the actions that a class can perform. Class properties define the class’s characteristics or data attributes. For example, a smart device has these properties:

    • Name: name of the device.

    • Category: type of the device, such as entertainment, utility, or cooking.

    • Device status: whether is is on, off, online, or offline. The device is considered online when it’s connected to the internet. Otherwise, it’s considered offline.

  • Class properties are defined in the class body. Example:

    class SmartDevice {
        val name = "Android TV"
        val category = "Entertainment"
        var deviceStatus = "online"
    
        fun turnOn() {
            println("Smart device is turned on.")
        }
    
        fun turnOff() {
            println("Smart device is turned off.")
        }
    }
    
    fun main() {
        val smartTvDevice = SmartDevice()
        println("Device name is: ${smartTvDevice.name}")
        smartTvDevice.turnOn()
        smartTvDevice.turnOff()
    }
    
  • Properties can do more than a variable can. For example, imagine that you create a class structure to represent a smart TV. One of the common actions that you perform is increase and decrease the volume. To represent this action in programming, you can create a property named speakerVolume, which holds the current volume level set on the TV speaker, but there’s a range in which the value for volume resides. The minimum volume one can set is 0, while the maximum is 100.

  • To ensure that the speakerVolume property never exceeds 100 or falls below 0, you can write a setter function. When you update the value of the property, you need to check whether the value is in the range of 0 to 100.

  • As another example, imagine that there’s a requirement to ensure that the name is always in uppercase. You can implement a getter function to convert the name property to uppercase.

  • Full syntax to define a mutable property:

    ../_images/unit2-pathway1-activity4-section5-f2cf50a63485599f_14401.png
  • When you don’t define the getter and setter function for a property, the Kotlin compiler internally creates the functions. For example, if you use the var keyword to define a speakerVolume property and assign it a 2 value, the compiler autogenerates the getter and setter functions as you can see in this code snippet:

var speakerVolume = 2
    get() = field
    set(value) {
        field = value
    }
  • You won’t see these lines in your code because they’re added by the compiler in the background.

  • The full syntax for an immutable property has two differences:

    • It starts with the val keyword.

    • The variables of val type are read-only variables, so they don’t have set() functions.

  • Properties use a backing field to hold a value in memory. A backing field is basically a class variable defined internally. A backing field is scoped to a property, which means that you can only access it through the get() or set() property functions.

  • To read the property value in the get() function or update the value in the set() function, you need to use the property’s backing field. It’s autogenerated by the Kotlin compiler and referenced with a field identifier.

  • Example: to update the property’s value in the set() function:

    var speakerVolume = 2
        set(value) {
            field = value
        }
    

    Warning

    Don’t use the property name to get or set a value. For example, in the set() function, if you try to assign the value parameter to the speakerVolume property itself, the code enters an endless loop because the Kotlin runtime tries to update the value for the speakerVolume property, which triggers a call to the setter function repeatedly.

  • Example: to ensure that the value assigned to the speakerVolume property is in the range of 0 to 100:

    var speakerVolume = 2
      set(value) {
          if (value in 0..100) {
              field = value
          }
      }
    

Define a constructor

  • A constructor specifies how the objects of the class are created. Constructors initialize an object, and make the object ready for use. The code inside the constructor executes when the object of the class is instantiated. You can define a constructor with or without parameters.

  • A default constructor is a constructor without parameters:

    class SmartDevice constructor() {
        ...
    }
    
  • Kotlin aims to be concise, so you can remove the constructor keyword if there are no annotations or visibility modifiers (more on this later), on the constructor.

    class SmartDevice() {
        ...
    }
    
  • You can also remove the parentheses if the constructor has no parameters:

    class SmartDevice {
        ...
    }
    
  • The Kotlin compiler autogenerates the default constructor. You won’t see the autogenerated default constructor in your code because it’s added by the compiler in the background.

  • A parameterized constructor is a constructor that accepts parameters.

  • In the SmartDevice class, the name and category properties are immutable. To maintain immutability but avoid hardcoded values, use a parameterized constructor to initialize them:

    class SmartDevice(val name: String, val category: String) {
    
        var deviceStatus = "online"
    
        fun turnOn() {
            println("Smart device is turned on.")
        }
    
        fun turnOff() {
            println("Smart device is turned off.")
        }
    }
    
  • The constructor now accepts parameters to set up its properties. The full syntax to instantiate an object using a parameterized constructor:

    ../_images/unit2-pathway1-activity4-section6-bbe674861ec370b6_14401.png

    Note

    If the class doesn’t have a default constructor and you attempt to instantiate the object without arguments, the compiler reports an error.

  • Example:

    SmartDevice("Android TV", "Entertainment")
    
  • Both arguments to the constructor are strings. It’s a bit unclear as to which parameter the value should be assigned. To fix this, you can create a constructor with named arguments:

    SmartDevice(name = "Android TV", category = "Entertainment")
    
  • There are two main types of constructors in Kotlin:

    • Primary constructor. A class can have only one primary constructor, which is defined as part of the class header. A primary constructor can be a default or parameterized constructor. The primary constructor doesn’t have a body. That means that it can’t contain any code.

    • Secondary constructor. A class can have multiple secondary constructors. You can define the secondary constructor with or without parameters. The secondary constructor can initialize the class and has a body, which can contain initialization code. If the class has a primary constructor, each secondary constructor needs to initialize the primary constructor.

  • You can use the primary constructor to initialize properties in the class header. The arguments passed to the constructor are assigned to the properties. The syntax:

    ../_images/unit2-pathway1-activity4-section6-aa05214860533041_14401.png
  • The secondary constructor is enclosed in the body of the class. Its syntax:

    ../_images/unit2-pathway1-activity4-section6-2dc13ef136009e98_14401.png
  • For example, imagine that you want to integrate an API developed by a smart device provider. The API returns a status code of Int type to indicate initial device status. The API returns a 0 if the device is offline and a 1 if the device is online. For any other integer value, the status is considered unknown. You can create a secondary constructor in the SmartDevice class to convert this statusCode parameter to string representation:

    class SmartDevice(val name: String, val category: String) {
        var deviceStatus = "online"
    
        constructor(name: String, category: String, statusCode: Int) : this(name, category) {
            deviceStatus = when (statusCode) {
                0 -> "offline"
                1 -> "online"
                else -> "unknown"
            }
        }
        ...
    }
    

Implement a relationship between classes

  • Inheritance lets you build a class upon the characteristics and behavior of another class. It helps you write reusable code and establish relationships between classes.

  • For example, there are many smart device types, such as smart TVs, smart lights, and smart switches. When you represent smart devices in programming, they share some common properties, such as a name, category, and status. They also have common behaviors, such as the ability to turn them on and off.

  • However, the way to turn on or turn off each smart device is different. For example, to turn on a TV, you might need to turn on the display, and then set up the last known volume level and channel. On the other hand, to turn on a light, you might only need to increase or decrease the brightness.

  • Also, each of the smart devices has more functions and actions that they can perform. For example, with a TV, you can adjust the volume and change the channel. With a light, you can adjust the brightness or color.

  • In short, all smart devices have different features, yet share some common characteristics. Duplicating these common characteristics to each of the smart device classes makes the difficult to maintain, hence we make the code reusable with inheritance.

  • To do so, create a SmartDevice parent class, and define these common properties and behaviors. Then, create child classes, such as the SmartTvDevice and SmartLightDevice classes, which inherit the properties of the parent class.

  • In programming terms, we say that the SmartTvDevice and SmartLightDevice classes extend the SmartDevice parent class. The parent class is also referred to as a superclass, and the child class as a subclass.

    ../_images/unit2-pathway1-activity4-section7-e4cb2f63c96f8c_14401.png
  • In Kotlin, all classes are final by default. Final means that you can’t extend them. To make a class extendable, it is necessary to use the open keyword.

  • The open keyword informs the compiler that a class is extendable, so that other subclasses can extend it.

    open class SmartDevice(val name: String, val category: String) {
        ...
    }
    
  • Syntax to extend a subclass from a superclass:

    ../_images/unit2-pathway1-activity4-section7-1ac63b66e6b5c224_14401.png
  • Example:

    class SmartTvDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    }
    
  • The constructor for SmartTvDevice takes in 2 parameters: deviceName and deviceCategory. These are simply passed on to the superclass constructor SmartDevice().

  • The full SmartTvDevice class might look like this:

    class SmartTvDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        var speakerVolume = 2
            set(value) {
                if (value in 0..100) {
                    field = value
                }
            }
    
        var channelNumber = 1
            set(value) {
                if (value in 0..200) {
                    field = value
                }
            }
    
        fun increaseSpeakerVolume() {
            speakerVolume++
            println("Speaker volume increased to $speakerVolume.")
        }
    
        fun nextChannel() {
            channelNumber++
            println("Channel number increased to $channelNumber.")
        }
    }
    
  • Sample code for the SmartLightDevice class:

    class SmartLightDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        var brightnessLevel = 0
            set(value) {
                if (value in 0..100) {
                  field = value
                }
            }
    
        fun increaseBrightness() {
            brightnessLevel++
            println("Brightness increased to $brightnessLevel.")
        }
    }
    
  • Inheritance establishes a relationship between two classes in something called an IS-A relationship. An object is also an instance of the class from which it inherits. In a HAS-A relationship, an object can own an instance of another class without actually being an instance of that class itself.

    ../_images/unit2-pathway1-activity4-section7-43ebe1f550d6c614_14401.png
  • IS-A relationships: the SmartDevice superclass and SmartTvDevice subclass have an IS-A relationship. Whatever the SmartDevice superclass can do, the SmartTvDevice subclass can do too. The relationship is unidirectional: every smart TV is a smart device, but not every smart device is a smart TV.

  • Example of an IS-A relationship:

    // Smart TV IS-A smart device.
    class SmartTvDevice : SmartDevice() {
    }
    
  • Don’t use inheritance only to achieve code reusability. Before you decide, check whether the two classes are related to each other. If they exhibit some relationship, check whether they really qualify for the IS-A relationship. Ask yourself, “Can I say a subclass is a superclass?”. For example, Android is an operating system.

  • HAS-A relationships: one class contains or uses another class. Example: a home has a smart TV. The HAS-A relationship is also known as composition.

  • The example below shows the SmartHome class, which contains smart devices. The SmartHome class lets us interact with the smart devices. The smart devices are the SmartTvDevice and SmartLightDevice.

    class SmartHome(
        val smartTvDevice: SmartTvDevice,
        val smartLightDevice: SmartLightDevice
    ) {
    
        fun turnOnTv() {
            smartTvDevice.turnOn()
        }
    
        fun turnOffTv() {
            smartTvDevice.turnOff()
        }
    
        fun increaseTvVolume() {
            smartTvDevice.increaseSpeakerVolume()
        }
    
        fun changeTvChannelToNext() {
            smartTvDevice.nextChannel()
        }
    
        fun turnOnLight() {
            smartLightDevice.turnOn()
        }
    
        fun turnOffLight() {
            smartLightDevice.turnOff()
        }
    
        fun increaseLightBrightness() {
            smartLightDevice.increaseBrightness()
        }
    
        fun turnOffAllDevices() {
            turnOffTv()
            turnOffLight()
        }
    }
    
Override superclass methods from subclasses
  • Even though the turn-on and turn-off functionality is supported by all smart devices, the way in which the smart devices perform the functionality differs. To provide this device-specific behavior, you need to override the turnOn() and turnOff() methods defined in the superclass. To override means to replace the code in the superclass with other code in the subclass.

  • To make a method extendable, use the open keyword.

    open class SmartDevice(val name: String, val category: String) {
    
        var deviceStatus = "online"
    
        open fun turnOn() {
            // function body
        }
    
        open fun turnOff() {
            // function body
        }
    }
    
  • The override keyword informs the Kotlin runtime to execute the code enclosed in the method defined in the subclass.

  • Sample code for the SmartLightDevice class:

    class SmartLightDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        var brightnessLevel = 0
            set(value) {
                if (value in 0..100) {
                    field = value
                }
            }
    
        fun increaseBrightness() {
            brightnessLevel++
            println("Brightness increased to $brightnessLevel.")
        }
    
        override fun turnOn() {
            deviceStatus = "on"
            brightnessLevel = 2
            println("$name turned on. The brightness level is $brightnessLevel.")
        }
    
        override fun turnOff() {
            deviceStatus = "off"
            brightnessLevel = 0
            println("Smart Light turned off")
        }
    }
    
  • Sample code for the SmartTvDevice class:

    class SmartTvDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        var speakerVolume = 2
            set(value) {
                if (value in 0..100) {
                    field = value
                }
            }
    
        var channelNumber = 1
            set(value) {
                if (value in 0..200) {
                    field = value
                }
            }
    
        fun increaseSpeakerVolume() {
            speakerVolume++
            println("Speaker volume increased to $speakerVolume.")
        }
    
        fun nextChannel() {
            channelNumber++
            println("Channel number increased to $channelNumber.")
        }
    
        override fun turnOn() {
            deviceStatus = "on"
            println(
                "$name is turned on. Speaker volume is set to $speakerVolume and channel number is " +
                    "set to $channelNumber."
            )
        }
    
        override fun turnOff() {
            deviceStatus = "off"
            println("$name turned off")
        }
    }
    
  • This is an example of polymorphism. When the turnOn() method of a SmartDevice variable is called, depending on what the actual value of the variable is, different implementations of the turnOn() method can be executed.

Reuse superclass code in subclasses
  • In the turnOn() and turnOff() methods, there’s similarity in how the deviceStatus variable is updated, whenever the methods are called in the SmartTvDevice and SmartLightDevice subclasses. The code is duplicated. It would be better to put it in the SmartDevice class instead. Then, from the subclass, call the common code using the super keyword.

  • Syntax for a subclass to call a method from the superclass:

    ../_images/unit2-pathway1-activity2-section7-18cc94fefe9851e0_14401.png
  • Sample code for the SmartTvDevice:

    class SmartTvDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        var speakerVolume = 2
            set(value) {
                if (value in 0..100) {
                    field = value
                }
            }
    
        var channelNumber = 1
            set(value) {
                if (value in 0..200) {
                    field = value
                }
            }
    
        fun increaseSpeakerVolume() {
            speakerVolume++
            println("Speaker volume increased to $speakerVolume.")
        }
    
        fun nextChannel() {
            channelNumber++
            println("Channel number increased to $channelNumber.")
        }
    
        override fun turnOn() {
            super.turnOn()
            println(
                "$name is turned on. Speaker volume is set to $speakerVolume and channel number is set to $channelNumber."
            )
        }
    
        override fun turnOff() {
            super.turnOff()
            println("$name turned off")
        }
    }
    
  • Sample code for the SmartLightDevice:

    class SmartLightDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        var brightnessLevel = 0
            set(value) {
                if (value in 0..100) {
                    field = value
                }
            }
    
        fun increaseBrightness() {
            brightnessLevel++
            println("Brightness increased to $brightnessLevel.")
        }
    
        override fun turnOn() {
            super.turnOn()
            brightnessLevel = 2
            println("$name turned on. The brightness level is $brightnessLevel.")
        }
    
        override fun turnOff() {
            super.turnOff()
            brightnessLevel = 0
            println("Smart Light turned off")
        }
    }
    
Override superclass properties from subclasses
  • Properties can also be overridden in subclasses.

  • Example:

    open class SmartDevice(val name: String, val category: String) {
    
        var deviceStatus = "online"
    
        open val deviceType = "unknown"
        ...
    }
    
    class SmartTvDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        override val deviceType = "Smart TV"
    
        ...
    }
    
    class SmartLightDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        override val deviceType = "Smart Light"
    
        ...
    
    }
    

Visibility modifiers

  • Visibility modifiers play an important role to achieve encapsulation:

    • In a class, they let you hide your properties and methods from unauthorized access outside the class.

    • In a package, they let you hide the classes and interfaces from unauthorized access outside the package.

    Note

    A module is a collection of source files and build settings that let you divide your project into discrete units of functionality. Your project can have one or many modules. You can independently build, test, and debug each module.

    A package is like a directory or a folder that groups related classes, whereas a module provides a container for your app’s source code, resource files, and app-level settings. A module can contain multiple packages.

  • Kotlin provides four visibility modifiers:

    • public: default visibility modifier. Makes the declaration accessible everywhere. The properties and methods that you want used outside the class are marked as public.

    • private: makes the declaration accessible in the same class or source file. There are likely some properties and methods that are only used inside the class, and that you don’t necessarily want other classes to use. These properties and methods can be marked with the private visibility modifier to ensure that another class can’t accidentally access them.

    • protected: makes the declaration accessible in subclasses. The properties and methods that you want used in the class that defines them and the subclasses are marked with the protected visibility modifier.

    • internal: makes the declaration accessible in the same module. The internal modifier is similar to private, but you can access internal properties and methods from outside the class as long as it’s being accessed in the same module.

  • By default, a class is publicly visible, and can be accessed by any package that imports it. Similarly, when you define or declare properties and methods in the class, by default they can be accessed outside the class through the class object. It’s essential to define proper visibility for code, primarily to hide properties and methods that other classes don’t need to access.

  • Analogy: Only some parts of a car are exposed to a driver, not all. The specifics of what parts comprise the car and how the car works internally are hidden by default. The car is intended to be as intuitive to operate as possible. Drivers want to focus on driving, not on the internals of the car. Developers also want to know just enough to be able to use a class, they don’t want too much information.

  • Visibility modifiers help you expose only the relevant parts of the code to other classes in your project, and ensure that certain code cannot be unintentionally used, which makes for code that’s easy to understand and less prone to bugs.

Visibility modifier for properties
  • Syntax to specify a visibility modifier for a property:

    ../_images/unit2-pathway1-activity4-section8-47807a890d237744_14401.png
  • Example:

    open class SmartDevice(val name: String, val category: String) {
    
        ...
    
        private var deviceStatus = "online"
    
        ...
    }
    
  • Syntax to specify a visibility modifier for a setter function:

    ../_images/unit2-pathway1-activity4-section8-cea29a49b7b26786_14401.png
  • For the SmartDevice class, the value of the deviceStatus property should be readable outside of the class through class objects. However, only the class and its children should be able to update or write the value. To implement this requirement, use the protected modifier on the set() function of the deviceStatus property.

    open class SmartDevice(val name: String, val category: String) {
    
        ...
    
        var deviceStatus = "online"
            protected set(value) {
              field = value
          }
    
        ...
    }
    
  • The set() function doesn’t performing any actions. It simply assigns the value parameter to the field variable. This is similar to the default implementation for property setters, hence the parentheses and body of set() can be omitted:

    open class SmartDevice(val name: String, val category: String) {
    
        ...
    
        var deviceStatus = "online"
            protected set
    
        ...
    }
    
  • Code for the SmartHome class, with a private setter for deviceTurnOnCount:

    class SmartHome(
        val smartTvDevice: SmartTvDevice,
        val smartLightDevice: SmartLightDevice
    ) {
    
        var deviceTurnOnCount = 0
            private set
    
        fun turnOnTv() {
            deviceTurnOnCount++
            smartTvDevice.turnOn()
        }
    
        fun turnOffTv() {
            deviceTurnOnCount--
            smartTvDevice.turnOff()
        }
    
        ...
    
        fun turnOnLight() {
            deviceTurnOnCount++
            smartLightDevice.turnOn()
        }
    
        fun turnOffLight() {
            deviceTurnOnCount--
            smartLightDevice.turnOff()
        }
    
        ...
    
    }
    
Visibility modifiers for methods
  • Syntax to specify a visibility modifier for a method:

    ../_images/unit2-pathway1-activity4-section8-e0a60ddc26b841de_14401.png
  • Example:

    class SmartTvDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        ...
    
        protected fun nextChannel() {
            channelNumber++
            println("Channel number increased to $channelNumber.")
        }
    
        ...
    }
    
Visibility modifiers for constructors
  • Syntax to specify a visibility modifier for a constructor:

    ../_images/unit2-pathway1-activity4-section8-6832575eba67f059_14401.png
  • If the modifier is specified for the primary constructor, it’s necessary to always keep the constructor keyword and parentheses.

  • Example:

    open class SmartDevice protected constructor (val name: String, val category: String) {
    
        ...
    
    }
    
Visibility modifiers for classes
  • Syntax to specify a visibility modifier for a class:

    ../_images/unit2-pathway1-activity4-section8-3ab4aa1c94a24a69_14401.png
  • Example:

    internal open class SmartDevice(val name: String, val category: String) {
    
        ...
    
    }
    
Choosing visibility modifiers
  • Ideally, strive for strict visibility of properties and methods, and declare them with the private modifier as often as possible. If you can’t keep them private, use the protected modifier. If you can’t keep them protected, use the internal modifier. If you can’t keep them internal, use the public modifier.

  • This table helps you determine the appropriate visibility modifiers based on where the property or methods of a class or constructor should be accessible:

    Modifier

    Accessible in same class

    Accessible in subclass

    Accessible in same module

    Accessible outside module

    private

    protected

    internal

    public

  • In the SmartTvDevice subclass, you shouldn’t allow the speakerVolume and channelNumber properties to be controlled from outside the class. These properties should be controlled only through the increaseSpeakerVolume() and nextChannel() methods.

    class SmartTvDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        private var speakerVolume = 2
            set(value) {
                if (value in 0..100) {
                    field = value
                }
            }
    
        private var channelNumber = 1
            set(value) {
                if (value in 0..200) {
                    field = value
                }
            }
    
        ...
    }
    
  • Similarly, in the SmartLightDevice subclass, the brightnessLevel property should be controlled only through the increaseLightBrightness() method.

    class SmartLightDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        ...
    
        private var brightnessLevel = 0
            set(value) {
                if (value in 0..100) {
                    field = value
                }
            }
    
        ...
    }
    

Define property delegates

  • Previously, we saw that properties in Kotlin use a backing field to hold their values in memory. The field identifier is used to reference it.

  • Currently, there is some range-check code to check whether values are within range for the speakerVolume, channelNumber, and brightnessLevel properties. The code is duplicated in the SmartTvDevice and SmartLightDevice classes. We can reuse the range-check code in a setter function with delegates. Instead of using a field, and a getter and setter function to manage the value, a delegate object handles the getter and setter functions for the property.

  • Syntax to create property delegates:

    ../_images/unit2-pathway1-activity4-section9-928547ad52768115_14401.png
  • We’ll pause here and briefly explain interfaces. An interface is a contract. Classes that implement an interface need to adhere to this contract. It focuses on what to do instead of how to do the action. An interface helps to achieve abstraction.

  • For example, before building a house, you inform the architect about what you want. You want a bedroom, living room, kitchen, etc. In short, you specify what you want and the architect specifies how to achieve it.

  • Syntax to create an interface:

    ../_images/unit2-pathway1-activity4-section9-bfe3fd1cd8c45b2a_14401.png
  • We’ve already learned how to extend a class and override its functionality. With interfaces, the class implements the interface. The class provides implementation details for the methods and properties declared in the interface. We’ll do something similar with the ReadWriteProperty interface to create the delegate.

  • It’s ok if we’re still not clear about what interfaces are, we’ll learn more about interfaces later.

  • A delegate class for the var type implements the ReadWriteProperty interface. A delegate class for the val type implments the ReadOnlyProperty interface.

  • We’ll create a delegate class, the RangeRegulator class that implements the ReadWriteProperty<Any?, Int> interface. Ignore the <Any?, Int> for now, more on that later.

    class RangeRegulator(
        initialValue: Int,
        private val minValue: Int,
        private val maxValue: Int
    ) : ReadWriteProperty<Any?, Int> {
    
        var fieldData = initialValue
    
        override fun getValue(thisRef: Any?, property: KProperty<*>): Int {
            return fieldData
        }
    
        override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
            if (value in minValue..maxValue) {
                fieldData = value
            }
        }
    }
    
    • The fieldData property is acts as the backing field for the variable. It is initialized with initialValue parameter.

    • The getValue() and setValue() methods act as the property’s getter and setter functions.

      • getValue() simply returns the fieldData property.

      • setValue() checks whether the value parameter is within minValue..maxValue, before assigning it to the fieldData property:

    Note

    KProperty is an interface that represents a declared property and lets you access the metadata on a delegated property. It’s out of scope of this mod. It’s enough to just have a high-level understanding about what the KProperty is.

  • Now, this range-check code can be re-used in the SmartTvDevice and SmartLightDevice classes. The RangeRegulator class acts as a delegate for the properties that need range checking.

  • In SmartTvDevice:

    class SmartTvDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        ...
    
        private var speakerVolume by RangeRegulator(initialValue = 2, minValue = 0, maxValue = 100)
        private var channelNumber by RangeRegulator(initialValue = 1, minValue = 0, maxValue = 200)
    
        ...
    
    }
    
  • In SmartLightDevice:

    class SmartLightDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        ...
    
        private var brightnessLevel by RangeRegulator(initialValue = 0, minValue = 0, maxValue = 100)
    
        ...
    
    }
    

Smart Home App code

  • Here’s the complete code for the Smart Home app.

    import kotlin.properties.ReadWriteProperty
    import kotlin.reflect.KProperty
    
    open class SmartDevice(val name: String, val category: String) {
    
        var deviceStatus = "online"
            protected set
    
        open val deviceType = "unknown"
    
        open fun turnOn() {
            deviceStatus = "on"
        }
    
        open fun turnOff() {
            deviceStatus = "off"
        }
    }
    
    class SmartTvDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        override val deviceType = "Smart TV"
    
        private var speakerVolume by RangeRegulator(initialValue = 2, minValue = 0, maxValue = 100)
    
        private var channelNumber by RangeRegulator(initialValue = 1, minValue = 0, maxValue = 200)
    
        fun increaseSpeakerVolume() {
            speakerVolume++
            println("Speaker volume increased to $speakerVolume.")
        }
    
        fun nextChannel() {
            channelNumber++
            println("Channel number increased to $channelNumber.")
        }
    
        override fun turnOn() {
            super.turnOn()
            println(
                "$name is turned on. Speaker volume is set to $speakerVolume and channel number is " +
                    "set to $channelNumber."
            )
        }
    
        override fun turnOff() {
            super.turnOff()
            println("$name turned off")
        }
    }
    
    class SmartLightDevice(deviceName: String, deviceCategory: String) :
        SmartDevice(name = deviceName, category = deviceCategory) {
    
        override val deviceType = "Smart Light"
    
        private var brightnessLevel by RangeRegulator(initialValue = 0, minValue = 0, maxValue = 100)
    
        fun increaseBrightness() {
            brightnessLevel++
            println("Brightness increased to $brightnessLevel.")
        }
    
        override fun turnOn() {
            super.turnOn()
            brightnessLevel = 2
            println("$name turned on. The brightness level is $brightnessLevel.")
        }
    
        override fun turnOff() {
            super.turnOff()
            brightnessLevel = 0
            println("Smart Light turned off")
        }
    }
    
    class SmartHome(
        val smartTvDevice: SmartTvDevice,
        val smartLightDevice: SmartLightDevice
    ) {
    
        var deviceTurnOnCount = 0
            private set
    
        fun turnOnTv() {
            deviceTurnOnCount++
            smartTvDevice.turnOn()
        }
    
        fun turnOffTv() {
            deviceTurnOnCount--
            smartTvDevice.turnOff()
        }
    
        fun increaseTvVolume() {
            smartTvDevice.increaseSpeakerVolume()
        }
    
        fun changeTvChannelToNext() {
            smartTvDevice.nextChannel()
        }
    
        fun turnOnLight() {
            deviceTurnOnCount++
            smartLightDevice.turnOn()
        }
    
        fun turnOffLight() {
            deviceTurnOnCount--
            smartLightDevice.turnOff()
        }
    
        fun increaseLightBrightness() {
            smartLightDevice.increaseBrightness()
        }
    
        fun turnOffAllDevices() {
            turnOffTv()
            turnOffLight()
        }
    }
    
    class RangeRegulator(
        initialValue: Int,
        private val minValue: Int,
        private val maxValue: Int
    ) : ReadWriteProperty<Any?, Int> {
    
        var fieldData = initialValue
    
        override fun getValue(thisRef: Any?, property: KProperty<*>): Int {
            return fieldData
        }
    
        override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
            if (value in minValue..maxValue) {
                fieldData = value
            }
        }
    }
    
    fun main() {
        var smartDevice: SmartDevice = SmartTvDevice("Android TV", "Entertainment")
        smartDevice.turnOn()
    
        smartDevice = SmartLightDevice("Google Light", "Utility")
        smartDevice.turnOn()
    }
    

Practice: Classes and Objects

  • If you need more practice, do these exercises. If not, feel free to skip them.

  • In the SmartDevice class, define a printDeviceInfo() method that prints a "Device name: $name, category: $category, type: $deviceType" string.

  • In the SmartTvDevice class, define a decreaseVolume() method that decreases the volume and a previousChannel() method that navigates to the previous channel.

  • In the SmartLightDevice class, define a decreaseBrightness() method that decreases the brightness.

  • In the SmartHome class, ensure that all actions can only be performed when each device’s deviceStatus property is set to an "on" string. Also, ensure that the deviceTurnOnCount property is updated correctly.

  • In the SmartHome class, define an decreaseTvVolume(), changeTvChannelToPrevious(), printSmartTvInfo(), printSmartLightInfo(), and decreaseLightBrightness() method.

  • Call the appropriate methods from the SmartTvDevice and SmartLightDevice classes in the SmartHome class.

  • In the main() function, call these added methods to test them.

Summary

  • There are four main principles of OOP: encapsulation, abstraction, inheritance, and polymorphism.

  • Classes are defined with the class keyword, and contain properties and methods.

  • Properties are similar to variables except properties can have custom getters and setters.

  • A constructor specifies how to instantiate objects of a class.

  • You can omit the constructor keyword when you define a primary constructor.

  • Inheritance makes it easier to reuse code.

  • The IS-A relationship refers to inheritance.

  • The HAS-A relationship refers to composition.

  • Visibility modifiers play an important role in the achievement of encapsulation.

  • Kotlin provides four visibility modifiers: the public, private, protected, and internal modifiers.

  • A property delegate lets you reuse the getter and setter code in multiple classes.

Use function types and lambda expressions

  • In Kotlin, functions are considered first-class constructs. This means that functions can be treated as a data type. You can store functions in variables, pass them to other functions as arguments, and return them from other functions.

  • Data types can store literal values — an Int type can store a 10 literal value, a String type can store a "Hello" literal value, etc. We can also declare function literals, which are called lambda expressions or lambdas for short.

Store a function in a variable

  • So far, we’ve learned how to declare functions with the fun keyword. A function can be called, which causes the code in the function body to execute.

  • As a first-class construct, functions are also data types, so you can store functions in variables, pass them to functions, and return them from functions. All this is made possible by lambda expressions.

  • We’ll see this in action with some trick-or-treating code, which refers to a tradition in many countries during which children dressed in costumes go from door to door and ask, “trick or treat?”. It helps if you try the code examples in Kotlin Playground.

  • Syntax to refer to a function as a value:

    ../_images/unit2-pathway1-activity5-section3-a9a9bfa88485ec67_14401.png
  • Example:

    fun main() {
        val trickFunction = ::trick
    }
    
    fun trick() {
        println("No treats!")
    }
    
  • Lambda expressions provide a concise syntax to define a function without the fun keyword. You can store a lambda expression directly in a variable.

  • Syntax for lambda expressions:

    ../_images/unit2-pathway1-activity5-section3-5e25af769cc200bc_14401.png
  • When defining a function with a lambda expression, a variable is used to refer to the function. Example:

    fun main() {
        val trickFunction = trick
        trick()
        trickFunction()
    }
    
    val trick = {
        println("No treats!")
    }
    
  • Using type inference, the Kotlin compiler inferred that the type of trick is a function. Next up, we’ll learn how to explicitly specify function types.

Use functions as a data type

  • Syntax for function types:

    ../_images/unit2-pathway1-activity5-section4-5608ac5e471b424b_14401.png
  • Examples:

    • () -> Unit: a function that doesn’t take any parameters, and doesn’t return anything

    • (Int, Int) -> String: a function that takes two Int parameters, and returns a String

  • In the code below, treat() behaves like trick(). Both variables have the same data type, even though only the treat variable declares it explicitly. The type of trick is inferred using type inference.

    val trick = {
        println("No treats!")
    }
    
    val treat: () -> Unit = {
        println("Have a treat!")
    }
    
    fun main() {
        val trickFunction = trick
        trick()
        trickFunction()
        treat()
    }
    
Use a function as a return type
  • A function is a data type, so you can use it like any other data type. You can even return functions from other functions.

  • Syntax for using a function type as a return type:

    ../_images/unit2-pathway1-activity5-section4-f16dd6ca0c1588f5_14401.png
  • In this example, the trickOrTreat() function returns another function. The returned function is of type () -> Unit.

    fun main() {
        val treatFunction = trickOrTreat(false)
        val trickFunction = trickOrTreat(true)
        treatFunction()
        trickFunction()
    }
    
    fun trickOrTreat(isTrick: Boolean): () -> Unit {
        if (isTrick) {
            return trick
        } else {
            return treat
        }
    }
    
    val trick = {
        println("No treats!")
    }
    
    val treat = {
        println("Have a treat!")
    }
    
Pass a function to another function as an argument
  • We’ll modify the trickOrTreat() function to allow an extra treat. The extra treat will be represented by a function. This function will be provided as an argument to trickOrTreat().

  • Syntax for specifying a function type as a parameter to another function:

    ../_images/unit2-pathway1-activity5-section4-8372d3b83d539fac_14401.png
  • Syntax for writing a lambda expression for a function that takes a parameter (the function type is omitted):

    ../_images/unit2-pathway1-activity5-section4-938d2adf25172873_14401.png
  • Example:

    fun main() {
        val coins: (Int) -> String = { quantity ->
            "$quantity quarters"
        }
    
        val cupcake: (Int) -> String = {
            "Have a cupcake!"
        }
    
        val treatFunction = trickOrTreat(false, coins)
        val trickFunction = trickOrTreat(true, cupcake)
        treatFunction()
        trickFunction()
    }
    
    fun trickOrTreat(isTrick: Boolean, extraTreat: (Int) -> String): () -> Unit {
        if (isTrick) {
            return trick
        } else {
            println(extraTreat(5))
            return treat
        }
    }
    
    val trick = {
        println("No treats!")
    }
    
    val treat = {
        println("Have a treat!")
    }
    
  • Let’s zoom in on this code:

    val coins: (Int) -> String = { quantity ->
        "$quantity quarters"
    }
    
  • The code creates a coins variable. The variable can contain a function.

    • The (Int) -> String means this function takes an Int parameter, and returns a String.

    • The Int parameter is named quantity. This could be named anything you like.

    • The return value is a String that contains the quantity parameter and the word quarters.

  • Let’s zoom in on this code:

    val cupcake: (Int) -> String = {
        "Have a cupcake!"
    }
    
  • The code creates a cupcake variable. The variable can contain a function.

    • The (Int) -> String means this function takes an Int parameter, and returns a String.

    • The Int parameter is not used in the function, which is fine. That’s why it’s missing the quantity -> ``, unlike the ``coins() function.

    • It always returns the string Have a cupcake!

  • Finally, let’s zoom in on this code:

    fun trickOrTreat(isTrick: Boolean, extraTreat: (Int) -> String): () -> Unit {
        if (isTrick) {
            return trick
        } else {
            println(extraTreat(5))
            return treat
        }
    }
    
  • The code declares a function named trickOrTreat. The function takes 2 parameters.

    • The isTrick parameter has a Boolean type.

    • The extraTreat parameter has a (Int) -> String type, meaning it can hold a function that takes an Int parameter, and returns a String.

    • The trickOrTreat function has a return type () -> Unit, which means it returns a function that takes no parameters and returns nothing.

Nullable function types
  • Like other data types, function types can be declared as nullable. In these cases, a variable could contain a function or it could be null.

  • Syntax to declare a function as nullable:

    ../_images/unit2-pathway1-activity4-section4-c8a004fbdc7469d_14401.png
  • For example, to make the extraTreat parameter nullable so that you don’t have to provide an extraTreat() function every time that you call the trickOrTreat() function:

    fun trickOrTreat(isTrick: Boolean, extraTreat: ((Int) -> String)?): () -> Unit {
        if (isTrick) {
            return trick
        } else {
            if (extraTreat != null) {
                println(extraTreat(5))
            }
            return treat
        }
    }
    
    fun main() {
        val coins: (Int) -> String = { quantity ->
            "$quantity quarters"
        }
    
        val treatFunction = trickOrTreat(false, coins)
        val trickFunction = trickOrTreat(true, null)
        treatFunction()
        trickFunction()
    }
    
    val trick = {
        println("No treats!")
    }
    
    val treat = {
        println("Have a treat!")
    }
    

Write lambda expressions with shorthand syntax

  • Lambda expressions make code more concise. We’ll explore some shorthand syntax to write lambda expressions more concisely.

Omit parameter name
  • When a function has a single parameter and you don’t provide a name, Kotlin implicitly assigns it the it name, so you can omit the parameter name and -> symbol, which makes your lambda expressions more concise.

  • Syntax for omitting the parameter name:

    ../_images/unit2-pathway1-activity5-section5-332ea7bade5062d6_14401.png
  • Example:

    val coins: (Int) -> String = {
        "$it quarters"
    }
    
  • $it can only be used for a single parameter. If a lambda expression has multiple parameters, they must be explicitly named.

Pass a lambda expression directly into a function
  • The coins() function is currently only used in one place:

    val treatFunction = trickOrTreat(false, coins)
    

    Instead of creating a variable to store it, it’s also possible to pass a lambda expression directly into trickOrTreat():

    val treatFunction = trickOrTreat(false, LAMBDA_EXPRESSION)
    
  • Lambda expressions are simply function literals, just like 0 is an integer literal, and "Slay" is a string literal. You can pass a lambda expression directly into a function call.

  • Syntax to pass a lambda expression into a function call:

    ../_images/unit2-pathway1-activity5-section5-39dc1086e2471ffc_14401.png
  • Example:

    fun main() {
        val treatFunction = trickOrTreat(false, { "$it quarters" })
        val trickFunction = trickOrTreat(true, null)
        treatFunction()
        trickFunction()
    }
    
Use trailing lambda syntax
  • When a function type is the last parameter of a function, you can write lambdas using another shorthand option.

  • Syntax for trailing lambdas:

    ../_images/unit2-pathway1-activity5-section5-3ee3176d612b54_14401.png
  • This makes code more readable because it separates the lambda expression from the other parameters, but doesn’t change what the code does.

  • Example:

    val treatFunction = trickOrTreat(false) { "$it quarters" }
    

Note

Composable functions are typically called using trailing lambda syntax.

Use the repeat() function

  • A higher-order function either returns a function, and/or takes a function as an argument. The trickOrTreat() function is an example of a higher-order function, because it takes a function of type ((Int) -> String)? as a parameter and returns a function of type () -> Unit.

  • Kotlin provides several useful higher-order functions. The repeat() function is one such higher-order function. It’s basically a for loop. The function signature:

    repeat(times: Int, action: (Int) -> Unit)
    
  • The times parameter is the number of times that the action should happen. The action parameter is a function that takes a single Int parameter and returns a Unit type.

  • The repeat() function is used to repeat code a specified number of times, similar to a for loop:

../_images/unit2-pathway1-activity5-section6-519a2e0f5d02687_14401.png
  • Instead of calling the trickFunction() function only once, you can call it multiple times with the repeat() function.

  • Example:

    fun main() {
        val treatFunction = trickOrTreat(false) { "$it quarters" }
        val trickFunction = trickOrTreat(true, null)
        repeat(4) {
            treatFunction()
        }
        trickFunction()
    }
    

Practice: Kotlin Fundamentals

  • Need some extra practice? These exercises test your understanding of the concepts that you studied. They’re based on real-world use cases, some of which you probably encountered before as a user.

  • If you don’t need any extra practice, feel free to skip these exercises.

  • Sample solutions are provided. The solutions are only one way to solve the exercises, so feel free to experiment however you feel comfortable.

Mobile notifications

  • Typically, your phone provides you with a summary of notifications.

  • Using the starter code below, write a program that prints the summary message based on the number of notifications received. The message should include:

    • The exact number of notifications when there are less than 100 notifications.

    • 99+ as the number of notifications when there are 100 notifications or more.

    fun main() {
        val morningNotification = 51
        val eveningNotification = 135
    
        printNotificationSummary(morningNotification)
        printNotificationSummary(eveningNotification)
    }
    
    fun printNotificationSummary(numberOfMessages: Int) {
        // Fill in the code.
    }
    
  • Complete the printNotificationSummary() function so that the program prints these lines:

    You have 51 notifications.
    Your phone is blowing up! You have 99+ notifications.
    

Movie ticket price

  • Movie tickets are typically priced differently based on the age of moviegoers.

  • Using the starter code below, write a program that calculates these age-based ticket prices:

    • A children’s ticket price of $15 for people 12 years old or younger.

    • A standard ticket price of $30 for people between 13 and 60 years old. On Mondays, discount the standard ticket price to $25 for this same age group.

    • A senior ticket price of $20 for people 61 years old and older. Assume that the maximum age of a moviegoer is 100 years old.

    • A -1 to indicate that the price is invalid when a user inputs an age outside of the age specifications.

    fun main() {
        val child = 5
        val adult = 28
        val senior = 87
    
        val isMonday = true
    
        println("The movie ticket price for a person aged $child is $${ticketPrice(child, isMonday)}.")
        println("The movie ticket price for a person aged $adult is $${ticketPrice(adult, isMonday)}.")
        println("The movie ticket price for a person aged $senior is $${ticketPrice(senior, isMonday)}.")
    }
    
    fun ticketPrice(age: Int, isMonday: Boolean): Int {
        // Fill in the code.
    }
    
  • Complete the ticketPrice() function so that the program prints these lines:

    The movie ticket price for a person aged 5 is $15.
    The movie ticket price for a person aged 28 is $25.
    The movie ticket price for a person aged 87 is $20.
    

Temperature converter

  • There are three main temperature scales used in the world: Celsius, Fahrenheit, and Kelvin.

  • Using the starter code below, write a program that converts a temperature from one scale to another with these formulas:

    • Celsius to Fahrenheit: ° F = 9/5 (° C) + 32

    • Kelvin to Celsius: ° C = K - 273.15

    • Fahrenheit to Kelvin: K = 5/9 (° F - 32) + 273.15

  • Note that the String.format("%.2f", /* measurement */ ) method is used to convert a number into a String type with 2 decimal places.

    fun main() {
        // Fill in the code.
    }
    
    fun printFinalTemperature(
        initialMeasurement: Double,
        initialUnit: String,
        finalUnit: String,
        conversionFormula: (Double) -> Double
    ) {
        val finalMeasurement = String.format("%.2f", conversionFormula(initialMeasurement)) // two decimal places
        println("$initialMeasurement degrees $initialUnit is $finalMeasurement degrees $finalUnit.")
    }
    
  • Complete the main() function so that it calls the printFinalTemperature() function and prints the following lines. You need to pass arguments for the temperature and conversion formula. Hint: you may want to use Double values to avoid Integer truncation during division operations.

    27.0 degrees Celsius is 80.60 degrees Fahrenheit.
    350.0 degrees Kelvin is 76.85 degrees Celsius.
    10.0 degrees Fahrenheit is 260.93 degrees Kelvin.
    

Song catalog

  • Imagine that you need to create a music-player app.

  • Create a class that can represent the structure of a song. The Song class must include these code elements:

    • Properties for the title, artist, year published, and play count

    • A property that indicates whether the song is popular. If the play count is less than 1,000, consider it unpopular.

    • A method that prints a song description in this format:

      “[Title], performed by [artist], was released in [year published].”

Internet profile

  • Oftentimes, you’re required to complete profiles for online websites that contain mandatory and non-mandatory fields. For example, you can add your personal information and link to other people who referred you to sign up for the profile.

  • Using the starter code below, write a program which prints out a person’s profile details.

    fun main() {
        val amanda = Person("Amanda", 33, "play tennis", null)
        val atiqah = Person("Atiqah", 28, "climb", amanda)
    
        amanda.showProfile()
        atiqah.showProfile()
    }
    
    class Person(val name: String, val age: Int, val hobby: String?, val referrer: Person?) {
        fun showProfile() {
          // Fill in code
        }
    }
    
  • Sample output:

    Name: Amanda
    Age: 33
    Likes to play tennis. Doesn't have a referrer.
    
    Name: Atiqah
    Age: 28
    Likes to climb. Has a referrer named Amanda, who likes to play tennis.
    

Foldable phones

  • Typically, a phone screen turns on and off when the power button is pressed. In contrast, if a foldable phone is folded, the main inner screen on a foldable phone doesn’t turn on when the power button is pressed.

  • Using the starter code below, write a FoldablePhone class that inherits from the Phone class. It should contain the following:

    • A property that indicates whether the phone is folded.

    • A different switchOn() function behavior than the Phone class so that it only turns the screen on when the phone isn’t folded.

    • Methods to change the folding state.

    class Phone(var isScreenLightOn: Boolean = false){
        fun switchOn() {
            isScreenLightOn = true
        }
    
        fun switchOff() {
            isScreenLightOn = false
        }
    
        fun checkPhoneScreenLight() {
            val phoneScreenLight = if (isScreenLightOn) "on" else "off"
            println("The phone screen's light is $phoneScreenLight.")
        }
    }
    

Special auction

  • Typically in an auction, the highest bidder determines the price of an item. In this special auction, if there’s no bidder for an item, the item is automatically sold to the auction house at the minimum price.

  • In the starter code below, you’re given an auctionPrice() function that accepts a nullable Bid? type as an argument:

    fun main() {
        val winningBid = Bid(5000, "Private Collector")
    
        println("Item A is sold at ${auctionPrice(winningBid, 2000)}.")
        println("Item B is sold at ${auctionPrice(null, 3000)}.")
    }
    
    class Bid(val amount: Int, val bidder: String)
    
    fun auctionPrice(bid: Bid?, minimumPrice: Int): Int {
      // Fill in the code.
    }
    
  • Complete the auctionPrice() function so that the program prints these lines:

    Item A is sold at 5000.
    Item B is sold at 3000.
    

Solution code

Solution: Mobile notifications

  • The solution uses an if/else statement to print the appropriate notification summary message based on the number of notification messages received:

    fun main() {
        val morningNotification = 51
        val eveningNotification = 135
    
        printNotificationSummary(morningNotification)
        printNotificationSummary(eveningNotification)
    }
    
    
    fun printNotificationSummary(numberOfMessages: Int) {
        if (numberOfMessages < 100) {
            println("You have ${numberOfMessages} notifications.")
        } else {
            println("Your phone is blowing up! You have 99+ notifications.")
        }
    }
    

Solution: Movie ticket price

  • The solution uses a when expression to return the appropriate ticket price based on the moviegoer’s age. It also uses a simple if/else expression for one of the when expression’s branches to add the additional condition for the standard ticket pricing.

  • The ticket price in the else branch returns a -1 value, which indicates that the price set is invalid for the else branch. A better implementation is for the else branch to throw an exception. You learn about exception handling in future units.

    fun main() {
        val child = 5
        val adult = 28
        val senior = 87
    
        val isMonday = true
    
        println("The movie ticket price for a person aged $child is \$${ticketPrice(child, isMonday)}.")
        println("The movie ticket price for a person aged $adult is \$${ticketPrice(adult, isMonday)}.")
        println("The movie ticket price for a person aged $senior is \$${ticketPrice(senior, isMonday)}.")
    }
    
    fun ticketPrice(age: Int, isMonday: Boolean): Int {
        return when(age) {
            in 0..12 -> 15
            in 13..60 -> if (isMonday) 25 else 30
            in 61..100 -> 20
            else -> -1
        }
    }
    

Solution: Temperature converter

  • The solution requires you to pass a function as a parameter to the printFinalTemperature() function. The most succinct solution passes lambda expressions as the arguments, uses the it parameter reference in place of the parameter names, and makes use of trailing lambda syntax.

    fun main() {
        printFinalTemperature(27.0, "Celsius", "Fahrenheit") { 9.0 / 5.0 * it + 32 }
        printFinalTemperature(350.0, "Kelvin", "Celsius") { it - 273.15 }
        printFinalTemperature(10.0, "Fahrenheit", "Kelvin") { 5.0 / 9.0 * (it - 32) + 273.15 }
    }
    
    
    fun printFinalTemperature(
        initialMeasurement: Double,
        initialUnit: String,
        finalUnit: String,
        conversionFormula: (Double) -> Double
    ) {
        val finalMeasurement = String.format("%.2f", conversionFormula(initialMeasurement)) // two decimal places
        println("$initialMeasurement degrees $initialUnit is $finalMeasurement degrees $finalUnit.")
    }
    

Solution: Song catalog

  • The solution contains a Song class with a default constructor that accepts all required parameters. The Song class also has an isPopular property that uses a custom getter function, and a method that prints the description of itself. You can create an instance of the class in the main() function and call its methods to test whether the implementation is correct. You can use underscores when writing large numbers such as the 1_000_000 value to make it more readable.

    fun main() {
        val brunoSong = Song("We Don't Talk About Bruno", "Encanto Cast", 2022, 1_000_000)
        brunoSong.printDescription()
        println(brunoSong.isPopular)
    }
    
    class Song(
        val title: String,
        val artist: String,
        val yearPublished: Int,
        val playCount: Int
    ){
        val isPopular: Boolean
            get() = playCount >= 1000
    
        fun printDescription() {
            println("$title, performed by $artist, was released in $yearPublished.")
        }
    }
    
  • When you call the println() function on the instance’s methods, the program may print this output:

    We Don't Talk About Bruno, performed by Encanto Cast, was released in 2022.
    true
    

Solution: Internet profile

  • The solution contains null checks in various if/else statements to print different text based on whether various class properties are null:

    fun main() {
        val amanda = Person("Amanda", 33, "play tennis", null)
        val atiqah = Person("Atiqah", 28, "climb", amanda)
    
        amanda.showProfile()
        atiqah.showProfile()
    }
    
    
    class Person(val name: String, val age: Int, val hobby: String?, val referrer: Person?) {
        fun showProfile() {
            println("Name: $name")
            println("Age: $age")
            if(hobby != null) {
                print("Likes to $hobby. ")
            }
            if(referrer != null) {
                print("Has a referrer named ${referrer.name}")
                if(referrer.hobby != null) {
                    print(", who likes to ${referrer.hobby}.")
                } else {
                    print(".")
                }
            } else {
                print("Doesn't have a referrer.")
            }
            print("\n\n")
        }
    }
    

Solution: Foldable phones

  • For the Phone class to be a parent class, you need to make the class open by adding the open keyword before the class name. To override the switchOn() method in the FoldablePhone class, you need to make the method in the Phone class open by adding the open keyword before the method.

  • The solution contains a FoldablePhone class with a default constructor that contains a default argument for the isFolded parameter. The FoldablePhone class also has two methods to change the isFolded property to either a true or false value. It also overrides the switchOn() method inherited from the Phone class.

  • You can create an instance of the class in the main() function and call its methods to test if the implementation is correct.

    open class Phone(var isScreenLightOn: Boolean = false){
        open fun switchOn() {
            isScreenLightOn = true
        }
    
        fun switchOff() {
            isScreenLightOn = false
        }
    
        fun checkPhoneScreenLight() {
            val phoneScreenLight = if (isScreenLightOn) "on" else "off"
            println("The phone screen's light is $phoneScreenLight.")
        }
    }
    
    class FoldablePhone(var isFolded: Boolean = true): Phone() {
        override fun switchOn() {
            if (!isFolded) {
                isScreenLightOn = true
            }
        }
    
        fun fold() {
            isFolded = true
        }
    
        fun unfold() {
            isFolded = false
        }
    }
    
    fun main() {
        val newFoldablePhone = FoldablePhone()
    
        newFoldablePhone.switchOn()
        newFoldablePhone.checkPhoneScreenLight()
        newFoldablePhone.unfold()
        newFoldablePhone.switchOn()
        newFoldablePhone.checkPhoneScreenLight()
    }
    
  • The output is the following:

    The phone screen's light is off.
    The phone screen's light is on.
    

Solution: Special auction

  • The solution uses the ?. safe call operator and the ?: Elvis operator to return the correct price:

    fun main() {
        val winningBid = Bid(5000, "Private Collector")
    
        println("Item A is sold at ${auctionPrice(winningBid, 2000)}.")
        println("Item B is sold at ${auctionPrice(null, 3000)}.")
    }
    
    class Bid(val amount: Int, val bidder: String)
    
    fun auctionPrice(bid: Bid?, minimumPrice: Int): Int {
        return bid?.amount ?: minimumPrice
    }
    

Dice Roller app

  • The Dice Roller app lets users tap a Button composable to roll a dice. The outcome of the roll is shown with an Image composable on the screen.

  • The app:

    ../_images/unit2-pathway2-activity2-section1-3e9a9f44c6c84634_14401.png

Solution code

  • We’ll start with the solution code, and walk through it to learn how it works.

  • https://github.com/google-developer-training/basic-android-kotlin-compose-training-dice-roller/tree/main

  • Branch: main

  • Clone:

    $ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-dice-roller.git
    
  • In MainActivity.kt:

    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContent {
                DiceRollerTheme {
                    Surface(
                        modifier = Modifier.fillMaxSize(),
                        color = MaterialTheme.colorScheme.background
                    ) {
                        DiceRollerApp()
                    }
                }
            }
        }
    }
    
    
    @Preview
    @Composable
    fun DiceRollerApp() {
        DiceWithButtonAndImage(modifier = Modifier
            .fillMaxSize()
            .wrapContentSize(Alignment.Center)
        )
    }
    
    
    @Composable
    fun DiceWithButtonAndImage(modifier: Modifier = Modifier) {
    
        // Result of the dice roll is a mutable state variable, with a default value of 1.
        // It's delegated to remember so that it retains its value across recompositions.
        var result by remember { mutableStateOf(1) }
    
        // Select the image resource based on the result of the dice roll.
        val imageResource = when(result) {
            1 -> R.drawable.dice_1
            2 -> R.drawable.dice_2
            3 -> R.drawable.dice_3
            4 -> R.drawable.dice_4
            5 -> R.drawable.dice_5
            else -> R.drawable.dice_6
        }
    
        // The DiceWithButtonAndImage composable comprises a column that contains an image to show the dice roll, and a button for the user to roll the dice.
        Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
            Image(painter = painterResource(imageResource), contentDescription = result.toString())
    
            Button(
                onClick = { result = (1..6).random() }, // Generate a random number between 1 and 6 when the button is clicked
            ) {
                // Text composable to display the button label
                Text(text = stringResource(R.string.roll), fontSize = 24.sp)
            }
        }
    }
    

Add a modifier

  • Compose uses a Modifier object to decorate or modify the behavior of Compose UI elements. This is used to style the the Dice Roller app’s UI components.

  • The DiceWithButtonAndImage() composable accepts a modifier parameter, which is of type Modifier. This allows the caller to pass in a Modifier object that can be used to modify the behavior and decoration of the UI components inside the DiceWithButtonAndImage() composable.

    @Composable
    fun DiceWithButtonAndImage(modifier: Modifier = Modifier) {
    
        // ...
    
        Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
            // ...
        }
    }
    
  • The modifier parameter has a default value of Modifier. This means that when a caller calls DiceWithButtonAndImage(), the caller can either omit the modifier parameter and use the default value, or the caller can specify another Modifier object to decorate or modify the behavior of the DiceWithButtonAndImage() composable.

  • As a best practice, the modifier parameter is passed on a composable’s first child. In this case, the DiceWithButtonAndImage()'s first child is the Column() composable. This allows the caller to decorate or modify the behavior of the Column() composable.

  • Now let’s look at how the DiceWithButtonAndImage() composable is called from the DiceRollerApp() function:

    @Preview
    @Composable
    fun DiceRollerApp() {
        DiceWithButtonAndImage(modifier = Modifier
            .fillMaxSize()
            .wrapContentSize(Alignment.Center)
        )
    }
    
  • Basically, DiceRollerApp() tells DiceWithButtonAndImage() to fill the maximum size of the parent container, to wrap the content, and to centering it within the available space.

  • How does this work?

    • Modifier is the “plain” modifier object.

    • Modifier.fillMaxSize() returns a Modifier object that fills the maximum size of the parent container.

    • Modifier.fillMaxSize().wrapContentSize(Alignment.Center) returns a Modifier object that fills the maximum size of the parent container, and wraps the content, centering it within the available space.

  • The wrapContentSize() method specifies that the available space should at least be as large as the components inside of it. However, because fillMaxSize() is used, if the components inside of the layout are smaller than the available space, an Alignment object can be passed to wrapContentSize() that specifies how the components should align within the available space. Alignment.Center specifies that a component centers both vertically and horizontally.

Create a vertical layout

  • Vertical layouts are created with the Column() function.

  • The Column() function is a composable layout that places its children in a vertical sequence. In the expected app design, you can see that the dice image displays vertically above the roll button:

    ../_images/unit2-pathway2-activity2-section4-7d70bb14948e3cc1_14401.png
  • Example code with a vertical layout:

    // The DiceWithButtonAndImage composable comprises a column that contains an image to show the dice roll, and a button for the user to roll the dice.
    Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
        Image(painter = painterResource(imageResource), contentDescription = result.toString())
    
        Button(
            onClick = { result = (1..6).random() }, // Generate a random number between 1 and 6 when the button is clicked
        ) {
            // Text composable to display the button label
            Text(text = stringResource(R.string.roll), fontSize = 24.sp)
        }
    }
    

    imports

    import import androidx.compose.foundation.layout.Column
    
  • The modifier argument from DiceWithButtonAndImage() is passed on to the Column(). This ensures that the composables in Column() adhere to any modifier constraints imposed.

  • horizontalAlignment = Alignment.CenterHorizontally ensures that the children within the column are centered horizontally on the device screen.

Add a button

  • The button code is shown below. It’s a Button() composable that contains a Text() composable to display the button label.

    Button(
        onClick = { result = (1..6).random() }, // Generate a random number between 1 and 6 when the button is clicked
    ) {
        // Text composable to display the button label
        Text(text = stringResource(R.string.roll), fontSize = 24.sp)
    }
    

    imports

    import androidx.compose.material3.Button
    import androidx.compose.ui.res.stringResource
    
  • It contains a Text composable, which uses stringResource(R.string.roll) as the text to display.

  • In res/values/strings.xml, this string is added:

    <string name="roll">Roll</string>
    

Add an image

  • The dice image displays the result when the user taps the Roll button. An image is added using the Image composable, and an image resource.

Import Drawables

  • For this app, the dice images are downloaded from dice_images.zip and stored in a dice_images folder. This folder contains six dice image files with dice values from 1 to 6.

  • Here are the steps to import the dice images. You don’t have to do these, it’s already done, but you can follow the steps in future if you need to import your own images.

    • Click View ➜ Tool Windows ➜ Resource Manager ➜ + ➜ Import Drawables.

    • Find and select the dice_images folder, and upload them. The uploaded images will appear like this:

      ../_images/unit2-pathway2-activity2-section6-12f17d0b37dd97d2_14401.png
    • Click Next ➜ Import. The images should appear in the Resource Manager pane.

    • The images can then be referred using their resource IDs:

      • R.drawable.dice_1

      • R.drawable.dice_2

      • R.drawable.dice_3

      • R.drawable.dice_4

      • R.drawable.dice_5

      • R.drawable.dice_6

Add an Image composable

  • The Image() composable is used to display the dice image:

    // Select the image resource based on the result of the dice roll.
    val imageResource = when(result) {
        1 -> R.drawable.dice_1
        2 -> R.drawable.dice_2
        3 -> R.drawable.dice_3
        4 -> R.drawable.dice_4
        5 -> R.drawable.dice_5
        else -> R.drawable.dice_6
    }
    
    // The DiceWithButtonAndImage composable comprises a column that contains an image to show the dice roll, and a button for the user to roll the dice.
    Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
        Image(painter = painterResource(imageResource), contentDescription = result.toString())
    
        Button(
            onClick = { result = (1..6).random() }, // Generate a random number between 1 and 6 when the button is clicked
        ) {
            // Text composable to display the button label
            Text(text = stringResource(R.string.roll), fontSize = 24.sp)
        }
    }
    

    imports

    import androidx.compose.foundation.Image
    import androidx.compose.ui.res.painterResource
    
  • The painterResource() function is used to load the image resource. Note the use of painterResource(imageResource) to access the drawable resource id: R.drawable.dice_1, R.drawable.dice_2, etc.

  • Composables inside of a Column composable will be stacked vertically. In this case, the Image is stacked on top of th button.

  • Any time you create an Image in your app, you should provide what is called a “content description.” Content descriptions attach descriptions to their respective UI components to increase accessibility. For more information about content descriptions, see Describe each UI element. The Image composable has a contentDescription parameter that accepts a string. This string is used to describe the image to users with visual impairments, and it is read by screen readers.

Add a Spacer

  • A Spacer composable is used to add space between 2 other composables. Example:

    Spacer(modifier = Modifier.height(16.dp))
    

    imports

    import androidx.compose.foundation.layout.height
    import androidx.compose.foundation.layout.Spacer
    import androidx.compose.ui.unit.dp
    
  • Typically, dp dimensions are changed in increments of 4.dp.

Build the dice roll logic

  • When the button is clicked, the app should perform some action. The onClick parameter of the Button() composable is used to specify the action.

  • In this case, the action is to generate a random number between 1 and 6:

    Button(
        onClick = { result = (1..6).random() }, // Generate a random number between 1 and 6 when the button is clicked
    ) {
        // Text composable to display the button label
        Text(text = stringResource(R.string.roll), fontSize = 24.sp)
    }
    

Use remember

  • Composables are stateless by default, which means that they don’t hold a value and can be recomposed any time by the system, which results in the value being reset. To store an object in memory, use the remember composable. Example:

    var result by remember { mutableStateOf(1) }
    

    imports

    import androidx.compose.runtime.mutableStateOf
    import androidx.compose.runtime.remember
    import androidx.compose.runtime.getValue
    import androidx.compose.runtime.setValue
    
  • The mutableStateOf() function returns an observable. You learn more about observables later, but for now this basically means that when the value of the result variable changes, a recomposition is triggered, and the UI refreshes, displaying the new value.

  • If the code is changed to the following, the UI will not refresh when the button is clicked:

    var result
    

Use the debugger (optional)

  • The debugger is an essential tool that lets you inspect the execution of the code that powers your Android app so that you can fix any bugs in it. It lets you specify points at which to suspend the execution of the code and manually interact with variables, methods, and other aspects of the code.

Starter code

Run the debugger

  • There are two ways to run the debugger alongside your app:

    • Attach the debugger to an existing app process that runs on a device or emulator.

    • Run the app with the debugger.

  • Both ways accomplish the same thing to a certain extent. Once you’re familiar with both ways, you can pick the one that you prefer or whichever one is required.

Attach the debugger to an app process

  • First, run your app.

  • Click Attach Debugger to Android Process.

    ../_images/unit2-pathway2-activity3-section4-9bd4c917bad56baa_14401.png
  • A Choose Process dialog opens in which you can choose the process to which you want to attach the debugger.

  • Select com.example.diceroller, click OK.

    ../_images/unit2-pathway2-activity3-section4-a4c65b5bc972bd46_14401.png
  • A Debug pane appears at the bottom of Android Studio with a message that indicates that the debugger is attached to the target device or emulator.

    ../_images/unit2-pathway2-activity3-section4-adad34e172cbc49a_14401.png
  • You attached the debugger to your app! Deets to be covered later. Next, you learn how to launch an app with the debugger already attached.

Run the app with the debugger

  • If you know that you want to use the debugger from the start, you can save time when you run the app with the debugger. Furthermore, if you want to debug code that only runs when the app launches, you need to launch the app with the debugger already attached.

  • To run the app with the debugger, in the Debug pane, click Stop, which should close the app on the emulator.

    ../_images/unit2-pathway2-activity3-section4-3c82e7f80c6c174d_14401.png
  • Click Debug ‘app’.

    ../_images/unit2-pathway2-activity3-section4-cbf915fde4e6b443_14401.png
  • The same Debug pane appears at the bottom of Android Studio with some console output.

    ../_images/unit2-pathway2-activity3-section4-f69e0370c2b5ad0e_14401.png

Use the debugger

The Debug pane

  • There are quite a few buttons across the top of the Debug pane, but these buttons don’t mean much right now, and most are grayed out and unclickable. This section covers the more-commonly used features found in the debugger. This codelab explains the other buttons as they become relevant.

  • When you first launch the debugger, you see a number of buttons in the Debug pane. At the top of the Debug pane, you see the Debugger and Console buttons. On some versions of Android Studio, instead of Debugger, you see Threads & Variables. They are the same.

    ../_images/unit2-pathway2-activity3-section5-5f35f4c555240598_14401.png
  • The Console button displays the logcat output of the app. If you have any log statements in your code, the output displays as that piece of code executes.

  • The Debugger (or Threads & Variables) button displays three separate panes, which are empty right now because you aren’t using the debugger:

    1. Frames display

    2. Evaluation and watch expression entry

    3. Variables pane

    ../_images/unit2-pathway2-activity3-section5-3752c14cdd27b8c4_14401.png

Use common debugger features

Set a breakpoint

  • One of the main features of the debugger is that it lets you stop execution on a specific line of code with a breakpoint.

  • To set a breakpoint in Android Studio, you need to navigate to a specific line of code and then click in the gutter next to the line number. To unset a breakpoint, you need to click an existing breakpoint in the gutter to make it disappear.

  • Example: here a breakpoint is set at where DiceRollerApp() is called.

    ../_images/unit2-pathway2-activity3-section5-4e2a6dba91f67ab11.gif
  • Try it yourself. Set a breakpoint where the imageResource variable is set, i.e. this line of code:

    val imageResource = when(result) {
    

Use the Resume Program button

  • In the last section, you set a breakpoint where the imageResource variable is set. This breakpoint causes the execution to suspend upon this instruction. When code execution is suspended with the debugger, you likely need to continue execution to continue running the app. The most direct way to do this is to use the Resume Program button.

  • To resume the program, follow these steps.

  • Click Debug ‘app’. You should see something like this image after the app launches:

    ../_images/unit2-pathway2-activity3-section5-c8a1660c4209458c_14401.png
  • Before you resume the program, it’s important to explain some of what you see on the screen when the debugger suspends execution:

    • Many of the buttons in the Debugger (or Threads & Variables) pane are now clickable.

    • The Frames pane displays a lot of information, which includes a highlighted reference to the line where the breakpoint was set.

    • The Variables pane displays a number of items, but this app doesn’t have many variables so there isn’t a lot of information that is relevant in the scope of this codelab at the moment. However, the ability to inspect variables is an essential feature of the debugger because it yields insight into what happens in the code at runtime. This codelab goes into more detail about how to inspect variables later.

  • If you look at the app on your device or emulator, you notice that the screen is blank because the app is suspended on a line of code. More specifically, execution stopped at the breakpoint and the UI hasn’t yet rendered.

  • Bear in mind that the app won’t always stop immediately only because a breakpoint was set. It depends on where you place a breakpoint in the code. In this case, you placed it on a line that executes when the app starts.

  • The key thing to remember is that the app only suspends at a breakpoint when an attempt to execute the line at which the breakpoint was set occurs. There are multiple ways to move the debugger forward, but for now you use the Resume Program button.

  • Click Resume Program.

    ../_images/unit2-pathway2-activity3-section5-7d664cd5dd8a2d9b_14401.png
  • You should now see something that looks like this image:

    ../_images/unit2-pathway2-activity3-section5-388c58b0f31f797e_14401.png
  • The majority of the information disappears and the buttons are once again unclickable. The app also appears as normal on your device or emulator. The reason for this is that the code is no longer suspended at the breakpoint and the app is in a normal running state. The debugger is attached, but it doesn’t do much until there’s an attempt to execute a line of code that has a breakpoint set. Leave this breakpoint in place because it’s useful in the following examples.

Use the Step Into button

  • The Step Into button of the debugger is a handy way to drill deeper into the code at runtime. If an instruction makes a call to a method or another piece of code, the Step Into button lets you enter the code without the need to navigate there manually before you launch the debugger to set a breakpoint.

  • Create a breakpoint in where the DiceRollerApp() function is called.

    ../_images/unit2-pathway2-activity3-section5-aa4337eabccc85d_14401.png
  • Click Debug ‘app’ to rerun the app with the debugger. The execution suspends at the line where the DiceRollerApp() function is called.

    Note

    • If the execution doesn’t suspend, most likely your Android Studio version is 2024.1.1 or later. In that case, create a breakpoint inside fun DiceRollerApp(), at

      DiceWithButtonAndImage(modifier = Modifier
      
    • Click Debug ‘app’ to rerun the app with the debugger.

    • The next steps will be different for you, try to understand what the steps are doing, and repeat them in your own Android Studio as far as possible.

  • Click Step Into.

    ../_images/unit2-pathway2-activity3-section5-73a80d2b10caea5f_14401.png
  • Now line 40 is highlighted and the Frames pane in the Debug pane indicates that the code is suspended on line 40.

    ../_images/unit2-pathway2-activity3-section5-ece32a03703a0531_14401.png
  • If you expand the Frames pane, you can see that the line after the highlighted line starts with invoke: followed by a line number, which is 32 in the previous image. This is what is referred to as the call stack. Essentially, it shows the chain of calls that lead the code execution to the current line. In this case, line 32 holds an instruction that calls the DiceRollerApp() function.

  • When you clicked the Step Into button when the debugger stopped at the breakpoint set on that function call, the debugger stepped into that function, which leads the execution to line 40 where the function is declared. The highlighted line indicates where the execution is suspended. If the lines after the highlighted line have line numbers associated with them, it’s an indication of the path of execution. In this particular case, the debugger indicates that an instruction on line 32 brought you to line 40.

  • Click Resume Program.

  • This should lead you to the original breakpoint that you set. You might understand a bit more about what you saw when you stopped execution in the first example. This is the same picture from the sixth step of the Resume program section:

    ../_images/unit2-pathway2-activity3-section5-76a1bef8e6cdf656_14401.png
  • In the call stack, you can see that the DiceWithButtonAndImage() function suspended on line 50 and the function was called from line 41 in the DiceRollerApp() function, which was called on line 32. The call-stack feature can help you understand the path of execution. This is very helpful when a function is called from many different places in the app.

  • The Step Into button provides a way to enter a function and suspend the execution without setting a breakpoint in the function itself. In this case, you set a breakpoint on the call to the DiceRollerApp() function. When you click the Step Into button, the execution suspends in the DiceRollerApp() function.

  • Dice Roller is a fairly small app because there aren’t many files, classes, or functions. When you work with bigger apps, the Step Into feature of the debugger becomes more useful because it gives you the option to drill down into the code without the need to navigate the code on your own.

Use the Step Over button

The Step Over button provides another means by which you can step through your app code at runtime. It moves the execution to the next line of code and advances the debugger.

  • Click Step Over.

    ../_images/unit2-pathway2-activity3-section5-25b1ea30948cfc31_14401.png
  • You now see that the debugger suspended the code on the next line of execution, which is line 51. You can proceed to step through each line, consecutively.

    ../_images/unit2-pathway2-activity3-section5-17e5998c76809c62_14401.png

Use the Step Out button

The Step Out button does the opposite of the Step Into button. Rather than drill down into the call stack, the Step Out button navigates up the call stack.

  • Click Step Out. Can you guess what line the program suspends on?

    ../_images/unit2-pathway2-activity3-section5-9e7ce3969c28f091_14401.png
  • Notice that the debugger stepped out of the DiceRollerApp() function and back to the line that called it.

    ../_images/unit2-pathway2-activity3-section5-fd19d30216463877_14401.png
  • The Step Out button is a useful tool when you find yourself too deep in a method call stack. It lets you work your way up the call stack without the need to step through all the code for each method that you stepped into.

Inspect variables

  • Earlier in the codelab, there was a brief description of the Variables pane, which provides a more in-depth explanation of how to inspect the variables shown in the pane to help you debug issues in your app.

  • Click the breakpoint to remove it from where the DiceRollerApp() function is called, but leave the breakpoint where the imageResource variable is set.

  • Click Debug ‘app’. You should see that the result$delegate variable is a MutableState with a value of 1. That is because when the variable is defined, it is instantiated with a mutableStateOf 1. MutableState means that the result variable holds a state that can be changed.

    Note

    The result$delegate variable in the Variables pane refers to the result variable in the code. The $delegate notation is there because the result variable is a remember delegate.

    ../_images/unit2-pathway2-activity3-section5-ac37c7436b5235c0_14401.png
  • Click Resume Program.

  • In the app, click Roll. Your code suspends at the breakpoint again and you may see a different value for the result$delegate variable.

  • In this image, the mutable state of the result$delegate variable holds a value of 2. This demonstrates how you can inspect variables at runtime with the debugger. In a more full-featured app, the value of a variable could potentially cause a crash. When you use the debugger to inspect variables, you can gain more insight into the details of the crash so that you can fix the bug.

    ../_images/unit2-pathway2-activity3-section5-a869ec4ba3b66fbf_14401.png

In-lesson practice: Lemonade app

  • This is meant to be done during the lesson.

App overview

  • You’re going to help us bring our vision of making digital lemonade to life! The goal is to create a simple, interactive app that lets you juice lemons when you tap the image on screen until you have a glass of lemonade. Consider it a metaphor or maybe just a fun way to pass the time!

  • Here’s how the app works:

    ../_images/unit2-pathway2-activity4-section2-dfcc3bc3eb43e4dd_14401.png
    1. When the user first launches the app, they see a lemon tree. There’s a label that prompts them to tap the lemon tree image to “select” a lemon from the tree.

    2. After they tap the lemon tree, the user sees a lemon. They are prompted to tap the lemon to “squeeze” it to make lemonade. They need to tap the lemon several times to squeeze it. The number of taps required to squeeze the lemon is different each time and is a randomly generated number between 2 to 4 (inclusive).

    3. After they’ve tapped the lemon the required number of times, they see a refreshing glass of lemonade! They are asked to tap the glass to “drink” the lemonade.

    4. After they tap the lemonade glass, they see an empty glass. They are asked to tap the empty glass to start again.

    5. After they tap the empty glass, they see the lemon tree and can begin the process again. More lemonade please!

  • Here are larger screenshots of how the app looks:

  • For each step of making lemonade, there’s a different image and text label on the screen, and different behavior for how the app responds to a click. For example, when the user taps the lemon tree, the app shows a lemon.

  • Your job is to build the app’s UI layout and implement the logic for the user to move through all the steps to make lemonade.

Get started

Create a project

  • In Android Studio, create a new project with the Empty Activity template with the following details:

    • Name: Lemonade

    • Package name: com.example.lemonade

    • Minimum SDK: 24

  • When the app has been successfully created and the project builds, then proceed with the next section.

Add images

  • You’re provided with four vector drawable files that you use in the Lemonade app.

  • Get the files:

    • Download the images for the app: lemonade_images.zip.

    • Add the images into the drawable folder of your app. If you don’t remember how to do this, see the Create an interactive Dice Roller app codelab.

  • Your project folder should look like the following screenshot in which the lemon_drink.xml, lemon_restart.xml, lemon_squeeze.xml, and lemon_tree.xml assets now appear under the res/drawable directory:

    ../_images/unit2-pathway2-activity4-section3-ccc5a4aa8a7e9fbd_14401.png
  • Double click a vector drawable file to see the image preview.

  • Select the Design pane (not the Code or Split views) to see a full-width view of the image.

    ../_images/unit2-pathway2-activity4-section3-3f3a1763ac414ec0_14401.png
  • After the image files are included in your app, you can refer to them in your code. For example, if the vector drawable file is called lemon_tree.xml, then in your Kotlin code, you can refer to the drawable using its resource ID in the format of R.drawable.lemon_tree.

Add string resources

  • Add the following strings to your project in the res/values/strings.xml file:

    • Tap the lemon tree to select a lemon

    • Keep tapping the lemon to squeeze it

    • Tap the lemonade to drink it

    • Tap the empty glass to start again

  • The following strings are also needed in your project. They’re not displayed on the screen in the user interface, but these are used for the content description of the images in your app to describe what the images are. Add these additional strings in your app’s strings.xml file:

    • Lemon tree

    • Lemon

    • Glass of lemonade

    • Empty glass

  • Give each string resource an appropriate identifier name that describes the value it contains. For example, for the string “Lemon", you can declare it in the strings.xml file with the identifier name lemon_content_description, and then refer to it in your code using the resource ID: R.string.lemon_content_description.

Steps of making lemonade

  • Now you have the string resources and image assets that are needed to implement the app. Here’s a summary of each step of the app and what is shown on the screen:

  • Step 1:

    • Text: Tap the lemon tree to select a lemon

    • Image: Lemon tree (lemon_tree.xml)

      ../_images/unit2-pathway2-activity4-section3-b2b0ae4400c0d06d_14401.png
  • Step 2:

    • Text: Keep tapping the lemon to squeeze it

    • Image: Lemon (lemon_squeeze.xml)

    ../_images/unit2-pathway2-activity4-section3-7c6281156d027a8_14401.png
  • Step 3:

    • Text: Tap the lemonade to drink it

    • Image: Full glass of lemonade (lemon_drink.xml)

    ../_images/unit2-pathway2-activity4-section3-38340dfe3df0f721_14401.png
  • Step 4:

    • Text: Tap the empty glass to start again

    • Image: Empty glass (lemon_restart.xml)

    ../_images/unit2-pathway2-activity4-section3-e9442e201777352b_14401.png

Add visual polish

  • To make your version of the app look like these final screenshots, there are a couple more visual adjustments to make in the app:

    • Increase the font size of the text so that it’s larger than the default font size (such as 18sp).

    • Add additional space in between the text label and the image below it, so they’re not too close to each other (such as 16dp).

    • Give the button an accent color and slightly rounded corners to let the users know that they can tap the image.

Plan out how to build the app

  • When building an app, it’s a good idea to get a minimal working version of the app done first. Then gradually add more functionality until you complete all desired functionality. Identify a small piece of end-to-end functionality that you can build first.

  • In the Lemonade app, notice that the key part of the app is transitioning from one step to another with a different image and text label shown each time. Initially, you can ignore the special behavior of the squeeze state because you can add this functionality later after you build the foundation of the app.

  • Below is a proposal of the high-level overview of the steps that you can take to build the app:

    1. Build the UI layout for the first step of making lemonade, which prompts the user to select a lemon from the tree. You can skip the border around the image for now because that’s a visual detail that you can add later.

      ../_images/unit2-pathway2-activity4-section4-b2b0ae4400c0d06d_14401.png
    2. Implement the behavior in the app so that when the user taps the lemon tree, the app shows a lemon image and its corresponding text label. This covers the first two steps of making lemonade.

      ../_images/unit2-pathway2-activity4-section4-adbf0d217e1ac77d_14401.png
    3. Add code so that the app displays the rest of the steps to make lemonade, when the image is tapped each time. At this point, a single tap on the lemon can transition to displaying the glass of lemonade.

      ../_images/unit2-pathway2-activity4-section4-33a36bcbe200af53_14401.png
    4. Add custom behavior for the lemon squeeze step, so that the user needs to “squeeze”, or tap, the lemon a specific number of times that’s randomly generated from 2 to 4.

      ../_images/unit2-pathway2-activity4-section4-a23102cb6c068174_14401.png
    5. Finalize the app with any other necessary visual polish details. For example, change the font size and add a border around the image to make the app look more polished. Verify that the app follows good coding practices, such as adhering to the Kotlin coding style guidelines and adding comments to your code.

  • If you can use these high-level steps to guide you in the implementation of the Lemonade app, go ahead and build the app on your own. If you find that you need additional guidance on each of these five steps, proceed to the next section.

Implement the app

Build the UI layout

  • First modify the app so that it displays the image of the lemon tree and its corresponding text label, which says Tap the lemon tree to select a lemon, in the center of the screen. There should also be 16dp of space in between the text and the image below it.

    ../_images/unit2-pathway2-activity4-section5-b2b0ae4400c0d06d_14401.png
  • If it helps, you can use the following starter code in the MainActivity.kt file:

    package com.example.lemonade
    
    import android.os.Bundle
    import androidx.activity.ComponentActivity
    import androidx.activity.compose.setContent
    import androidx.compose.foundation.layout.fillMaxSize
    import androidx.compose.material3.MaterialTheme
    import androidx.compose.material3.Surface
    import androidx.compose.material3.Text
    import androidx.compose.runtime.Composable
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.tooling.preview.Preview
    import com.example.lemonade.ui.theme.LemonadeTheme
    
    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContent {
                LemonadeTheme {
                    LemonApp()
                }
            }
        }
    }
    
    @Composable
    fun LemonApp() {
        // A surface container using the 'background' color from the theme
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colorScheme.background
        ) {
            Text(text = "Hello there!")
        }
    }
    
    @Preview(showBackground = true)
    @Composable
    fun DefaultPreview() {
        LemonadeTheme {
            LemonApp()
        }
    }
    
  • This code is similar to the code that’s autogenerated by Android Studio. However, instead of a Greeting() composable, there’s a LemonApp() composable defined and it doesn’t expect a parameter. The DefaultPreview() composable is also updated to use the LemonApp() composable so you can preview your code easily.

  • After you enter this code in Android Studio, modify the LemonApp() composable, which should contain the contents of the app. Here are some questions to guide your thought process:

    • What composables will you use?

    • Is there a standard Compose layout component that can help you arrange the composables into the desired positions?

Important

To make your app accessible for more users, remember to set content descriptions on the images to describe what the image contains.

  • Go and implement this step so that you have the lemon tree and text label displayed in your app, when your app launches. Preview your composable in Android Studio to see how the UI looks as you modify your code. Run the app to ensure that it looks like the screenshot that you saw earlier in this section.

  • Return to these instructions when you’re done, if you want more guidance on how to add behavior when the image is tapped on.

Add click behavior

  • Next you will add code so that when the user taps the image of the lemon tree, the image of the lemon appears along with the text label Keep tapping the lemon to squeeze it. In other words, when you tap the lemon tree, it causes the text and image to change.

../_images/unit2-pathway2-activity4-section5-adbf0d217e1ac77d_14401.png
  • Earlier in this pathway, you learned how to make a button clickable. In the case of the Lemonade app, there’s no Button composable. However, you can make any composable, not just buttons, clickable when you specify the clickable modifier on it. For an example, see the clickable documentation page.

  • What should happen when the image is clicked? The code to implement this behavior is non-trivial, so take a step back to revisit a familiar app.

Look at the Dice Roller app

  • Revisit the code from the Dice Roller app to observe how the app displays different dice images based on the value of the dice roll:

    ...
    
    @Composable
    fun DiceWithButtonAndImage(modifier: Modifier = Modifier) {
        var result by remember { mutableStateOf(1) }
        val imageResource = when(result) {
            1 -> R.drawable.dice_1
            2 -> R.drawable.dice_2
            3 -> R.drawable.dice_3
            4 -> R.drawable.dice_4
            5 -> R.drawable.dice_5
            else -> R.drawable.dice_6
        }
        Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
            Image(painter = painterResource(id = imageResource), contentDescription = result.toString())
            Button(onClick = { result = (1..6).random() }) {
                Text(stringResource(id = R.string.roll))
            }
        }
    }
    
    ...
    
  • Answer these questions about the Dice Roller app code:

    • Which variable’s value determines the appropriate dice image to display?

    • What action from the user triggers that variable to change?

  • The DiceWithButtonAndImage() composable function stores the most recent dice roll, in the result variable, which was defined with the remember composable and mutableStateOf() function in this line of code:

    var result by remember { mutableStateOf(1) }
    
  • When the result variable gets updated to a new value, Compose triggers recomposition of the DiceWithButtonAndImage() composable, which means that the composable will execute again. The result value is remembered across recompositions, so when the DiceWithButtonAndImage() composable runs again, the most recent result value is used. Using a when statement on the value of the result variable, the composable determines the new drawable resource ID to show and the Image composable displays it.

Apply what you learned to the Lemonade app

  • Now answer similar questions about the Lemonade app:

  • Is there a variable that you can use to determine what text and image should be shown on the screen? Define that variable in your code.

  • Can you use conditionals in Kotlin to have the app perform different behavior based on the value of that variable? If so, write that conditional statement in your code.

  • What action from the user triggers that variable to change? Find the appropriate place in your code where that happens. Add code there to update the variable.

Note

You may want to represent each step of making lemonade with a number. For example, step 1 of the app has the image of the lemon tree and clicking the image goes to step 2 of the app. This can help you organize which text string goes with which image.

../_images/unit2-pathway2-activity4-section5-270ecd406fc30120_14401.png
  • This section can be quite challenging to implement and requires changes in multiple places of your code to work correctly. It’s ok to feel discouraged if the app doesn’t work as you expect right away. Remember that there are multiple correct ways to implement this behavior.

  • When you’re done, run the app and verify that it works. When you launch the app, it should show the image of the lemon tree and its corresponding text label. A single tap of the image of the lemon tree should update the text label and show the image of the lemon. A tap on the lemon image shouldn’t do anything for now.

Add remaining steps

  • Now your app can display two of the steps to make lemonade! At this point, your LemonApp() composable may look something like the following code snippet. It’s okay if your code looks slightly different as long as the behavior in the app is the same.

    ...
    @Composable
    fun LemonApp() {
        // Current step the app is displaying (remember allows the state to be retained
        // across recompositions).
        var currentStep by remember { mutableStateOf(1) }
    
        // A surface container using the 'background' color from the theme
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colorScheme.background
        ) {
            when (currentStep) {
                1 -> {
                    Column (
                        horizontalAlignment = Alignment.CenterHorizontally,
                        verticalArrangement = Arrangement.Center,
                        modifier = Modifier.fillMaxSize()
                    ){
                        Text(text = stringResource(R.string.lemon_select))
                        Spacer(modifier = Modifier.height(32.dp))
                        Image(
                            painter = painterResource(R.drawable.lemon_tree),
                            contentDescription = stringResource(R.string.lemon_tree_content_description),
                            modifier = Modifier
                                .wrapContentSize()
                                .clickable {
                                    currentStep = 2
                                }
                        )
                    }
                }
                2 -> {
                    Column (
                        horizontalAlignment = Alignment.CenterHorizontally,
                        verticalArrangement = Arrangement.Center,
                        modifier = Modifier.fillMaxSize()
                    ){
                        Text(text = stringResource(R.string.lemon_squeeze))
                        Spacer(modifier = Modifier.height(32
                            .dp))
                        Image(
                            painter = painterResource(R.drawable.lemon_squeeze),
                            contentDescription = stringResource(R.string.lemon_content_description),
                            modifier = Modifier.wrapContentSize()
                        )
                    }
                }
            }
        }
    }
    ...
    
  • Next you’ll add the rest of the steps to make lemonade. A single tap of the image should move the user to the next step of making lemonade, where the text and image both update. You will need to change your code to make it more flexible to handle all steps of the app, not just the first two steps.

    ../_images/unit2-pathway2-activity4-section5-2c0f70529e0cf69d_14401.png
  • To have different behavior each time that the image is clicked, you need to customize the clickable behavior. More specifically, the lambda that’s executed when the image is clicked needs to know which step we’re moving to.

  • You may start to notice that there’s repeated code in your app for each step of making lemonade. For the when statement in the previous code snippet, the code for case 1 is very similar to case 2 with small differences. If it’s helpful, create a new composable function, called LemonTextAndImage() for example, that displays text above an image in the UI. By creating a new composable function that takes some input parameters, you have a reusable function that’s useful in multiple scenarios as long as you change the inputs that you pass in. It’s your job to figure out what the input parameters should be. After you create this composable function, update your existing code to call this new function in relevant places.

  • Another advantage to having a separate composable like LemonTextAndImage() is that your code becomes more organized and robust. When you call LemonTextAndImage(), you can be sure that both the text and image will get updated to the new values. Otherwise, it’s easy to accidentally miss one case where an updated text label is displayed with the wrong image.

  • Here’s one additional hint: You can even pass in a lambda function to a composable. Be sure to use function type notation to specify what type of function should be passed in. In the following example, a WelcomeScreen() composable is defined and accepts two input parameters: a name string and an onStartClicked() function of type () -> Unit. That means that the function takes no inputs (the empty parentheses before the arrow) and has no return value ( the Unit following the arrow). Any function that matches that function type () -> Unit can be used to set the onClick handler of this Button. When the button is clicked, the onStartClicked() function is called.

    @Composable
    fun WelcomeScreen(name: String, onStartClicked: () -> Unit) {
        Column {
            Text(text = "Welcome $name!")
            Button(
                onClick = onStartClicked
            ) {
                Text("Start")
            }
        }
    }
    
  • Passing in a lambda to a composable is a useful pattern because then the WelcomeScreen() composable can be reused in different scenarios. The user’s name and the button’s onClick behavior can be different each time because they’re passed in as arguments.

  • With this additional knowledge, go back to your code to add the remaining steps of making lemonade to your app.

  • Return to these instructions if you want additional guidance on how to add the custom logic around squeezing the lemon a random number of times.

Add squeeze logic

  • Great job! Now you have the basis of the app. Tapping the image should move you from one step to the next. It’s time to add the behavior of needing to squeeze the lemon multiple times to make lemonade. The number of times that the user needs to squeeze, or tap, the lemon should be a random number between 2 to 4 (inclusive). This random number is different each time that the user picks a new lemon from the tree.

  • Here are some questions to guide your thought process:

    • How do you generate random numbers in Kotlin?

    • At what point in your code should you generate the random number?

    • How do you ensure that the user tapped the lemon the required number of times before moving to the next step?

    • Do you need any variables stored with the remember composable so that the data doesn’t get reset every time the screen is redrawn?

  • When you’re done implementing this change, run the app. Verify that it takes multiple taps of the image of the lemon to move to the next step, and that the number of taps required each time is a random number between 2 and 4. If a single tap of the lemon image displays the lemonade glass, go back to your code to figure out what’s missing and try again.

  • Return to these instructions if you want additional guidance on how to finalize the app.

Finalize the app

  • You’re almost done! Add some last details to polish up the app.

  • As a reminder, here are the final screenshots of how the app looks:

  • Finishing touches:

    • Vertically and horizontally center the text and images within the screen.

    • Set the font size of the text to 18sp.

    • Add 16dp of space between the text and image.

    • Add a thin border of 2dp around the images with slightly rounded corners of 4dp. The border has an RGB color value of 105 for red, 205 for green, and 216 for blue. For examples of how to add a border, you can Google search it. Or you can refer to the documentation on Border.

  • When you’ve completed these changes, run your app and then compare it with the final screenshots to ensure that they match.

  • As part of good coding practices, go back and add comments to your code, so that anyone who reads your code can understand your thought process more easily. Remove any import statements at the top of your file that aren’t used in your code. Ensure that your code follows the Kotlin style guide. All these efforts will help make your code more readable by other people and easier to maintain!

  • Well done! You did an amazing job implementing the Lemonade app! That was a challenging app with many parts to figure out. Now treat yourself to a refreshing glass of lemonade. Cheers!

Solution code