UI development was dominated by an imperative, XML-based system. • We built UIs using XML layout fi les and managed their state programmatically in Activities and Fragments. • This approach was powerful but often led to complex state management and boilerplate code.
modern toolkit designed to simplify UI development. • It embraces a declarative paradigm. Instead of telling the UI how to change, you simply describe what it should look like for a given state. • This shift to a state-driven UI simpli fi es development and reduces bugs.
fi eld projects. They have a history and a codebase built with the traditional View system. • We often need to use mature, well-supported libraries, like the Google Maps SDK, that were not designed for Compose. • The Problem: How do we smoothly integrate a MapView (a traditional Android View) into a Compose UI tree?
ffi cial interoperability solution from the Compose team. • It acts as a container for an old-school Android View. • The AndroidView composable is built to manage the View's lifecycle, layout, and state from within a Compose function.
lambda to create the view and an update lambda to recon fi gure it. • factory: Called once to create the View instance. It's a key part of managing resource-heavy Views. • update: Called on every recomposition. This is where you pass state from Compose to the View.
= { context -> // This is only called once MyCustomView(context) }, update = { view -> // This is called on every recomposition view.setText(myState) } ) } •
AndroidView approach is su ff i cient. • However, MapView has its own complex lifecycle requirements. • It needs to be noti fi ed of its host's lifecycle events (onCreate, onResume, etc.) to initialize properly and manage its resources. • Simply wrapping it in AndroidView won't work because AndroidView doesn't automatically call these methods.
an Activity override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mapView.onCreate(savedInstanceState) } override fun onResume() { super.onResume() mapView.onResume() } // ...and so on for all lifecycle methods
composable's lifecycle information? • A composable itself doesn't have a direct lifecycle like a Fragment. • The solution: We need to fi nd a way to observe the lifecycle of the composable's host (the Activity or Fragment).
a composable function that creates an "e ff ect" tied to the composable's lifecycle. • The code inside DisposableE ff ect runs when the composable enters the composition. • The onDispose block runs when the composable leaves the composition. This is our crucial cleanup step.
carefully managed with a LifecycleEventObserver. • This observer should only be active when the MapView is actually attached to a window. • We use an AndroidView's factory and onRelease callbacks to create and clean up the MapView and its associated observer. • The MapLifecycleEventObserver itself is a state machine, ensuring MapView calls are made in the correct sequence.
a simple when statement. • It maintains its own currentLifecycleState to ensure MapView methods are invoked in a valid order. • It handles transitions both up (e.g., ON_CREATE -> ON_START) and down (e.g., ON_PAUSE -> ON_STOP). • This is a more robust pattern that prevents incorrect state transitions and potential crashes.
: LifecycleEventObserver { private var currentLifecycleState: Lifecycle.State = Lifecycle.State.INITIALIZED override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { when (event) { // [mapView.onDestroy] is only invoked from AndroidView->onRelease. Lifecycle.Event.ON_DESTROY -> moveToBaseState() else -> moveToLifecycleState(event.targetState) } } /** * Move down to [Lifecycle.State.CREATED] but only if [currentLifecycleState] is actually above that. * It's theoretically possible that [currentLifecycleState] is still in [Lifecycle.State.INITIALIZED] state. * */ fun moveToBaseState() { if (currentLifecycleState > Lifecycle.State.CREATED) { moveToLifecycleState(Lifecycle.State.CREATED) } } •
= Lifecycle.Event.downFrom(currentLifecycleState) ?: error("no event down from $currentLifecycleState") invokeEvent(event) } private fun moveUp() { val event = Lifecycle.Event.upFrom(currentLifecycleState) ?: error("no event up from $currentLifecycleState") invokeEvent(event) }
integrated a MapView into a composable. • The map is initialized, managed, and cleaned up correctly, preventing memory leaks and crashes. • However, this is only the fi rst step. The API is still too low-level for a good developer experience.
be declarative and state-driven. • Users of our library shouldn't have to think about MapViews, Lifecycles, or Bundles. • Our job is to create a clean, elegant facade on top of the complex implementation.
method to change the UI. map.animateCamera(...). • Declarative (New Way): You change a state object, and the UI reacts. GoogleMap(cameraPositionState = newPosition). • This is the core design philosophy of Compose, and we must follow it
= CameraPosition(LatLng(0.0, 0.0), 0f, 0f, 0f) ) { /** * Whether the camera is currently moving or not. This includes any kind of movement: * panning, zooming, or rotation. */ public var isMoving: Boolean by mutableStateOf(false) internal set /** * The reason for the start of the most recent camera moment, or * [CameraMoveStartedReason.NO_MOVEMENT_YET] if the camera hasn't moved yet or * [CameraMoveStartedReason.UNKNOWN] if an unknown constant is received from the Maps SDK. */ public var cameraMoveStartedReason: CameraMoveStartedReason by mutableStateOf( CameraMoveStartedReason.NO_MOVEMENT_YET ) internal set /** * Returns the current [Projection] to be used for converting between screen * coordinates and lat/lng. */ public val projection: Projection? get() = map?.projection
Current position of the camera on the map. */ public var position: CameraPosition get() = rawPosition set(value) { synchronized(lock) { val map = map if (map == null) { rawPosition = value } else { map.moveCamera(CameraUpdateFactory.newCameraPosition(value)) } } } // Used to perform side effects thread-safely. // Guards all mutable properties that are not `by mutableStateOf`. private val lock = Unit // The map currently associated with this CameraPositionState. // Guarded by `lock`. private var map: GoogleMap? by mutableStateOf(null) // An action to run when the map becomes available or unavailable. // represents a mutually exclusive mutation to perform while holding `lock`. // Guarded by `lock`. private var onMapChanged: OnMapChangedCallback? by mutableStateOf(null)
if (this.map == null && map == null) return if (this.map != null && map != null) { error("CameraPositionState may only be associated with one GoogleMap at a time") } this.map = map if (map == null) { isMoving = false } else { map.moveCamera(CameraUpdateFactory.newCameraPosition(position)) } onMapChanged?.let { // Clear this first since the callback itself might set it again for later onMapChanged = null it.onMapChangedLocked(map) } } }
underlying MapView fi res a callback. • The library code listens to this callback. • It then updates the cameraPositionState to re fl ect the new position.
just for creating MapViews. • It's essential for creating and retaining the state holders like cameraPositionState. • This ensures that the state survives recompositions and remains a single source of truth.
(e.g., WebView, a custom chart). • Assess Lifecycle Needs: Use a LifecycleObserver if the View has special needs. • Bridge with AndroidView: Host the View in a composable. • Manage State: Use remember and state holders to manage the View's state. • Design a Declarative API: Hide the imperative implementation.
if not used correctly. Minimize recompositions. • Interactions: Ensure all user interactions are captured and correctly update the state. • Testing: Remember that you'll have to test the underlying View separately from the composable.
is not without cost. • An AndroidView is a heavyweight composable. Placing it in your UI can introduce performance overhead. • Recomposition is expensive when it causes the underlying AndroidView to be re-created or re-con fi gured frequently. • Be mindful of the overhead of creating complex View hierarchies, especially inside a LazyColumn or LazyRow.
state to the AndroidView's update block. • Use remember: Cache expensive objects to prevent recreation on every recomposition. This is why we used it for MapView and CameraPositionState. • Avoid AndroidView in a LazyColumn or LazyRow: It can cause signi fi cant performance issues as items are scrolled on and o ff -screen. • Measure and Pro fi le: Use tools like the Layout Inspector and the Pro fi ler to identify performance bottlenecks.
a modern UI toolkit and a mature ecosystem of libraries. • Interoperability allows us to use both, building on the best parts of each world. • The techniques we've discussed are a powerful way to bring any legacy Android code into the modern Compose era.