Declarative UI
Migrating Legacy Mobile Apps to Modern Declarative Frameworks
Discover practical strategies for incrementally replacing XML layouts and Storyboards with modern code using interoperability layers and hybrid architectures.
In this article
The Core Friction of Imperative UI
For over a decade, mobile development relied on an imperative model where developers manually manipulated a tree of view objects. In this world, you were responsible for finding a specific view by its identifier and then calling methods to change its text, color, or visibility. This approach creates a massive mental overhead because the developer must keep the current state of the application perfectly synchronized with the visual representation.
As applications grow in complexity, the number of possible states increases exponentially, leading to synchronization bugs. If you update the user profile in the database but forget to update the corresponding label in the header, the UI becomes a lie. These inconsistencies are the primary motivation for moving toward a declarative system where the UI is simply a reflection of the current data.
The greatest source of bugs in traditional mobile development is the divergence between the internal state of the application and the state represented by the UI views.
Declarative frameworks like Jetpack Compose and SwiftUI solve this by making the UI a function of state. Instead of writing code that describes how to change the UI, you write code that describes what the UI should look like for a given state. When the state changes, the framework automatically determines which parts of the interface need to be updated and handles the transition for you.
The Single Source of Truth
Transitioning to a declarative mindset requires embracing the concept of a single source of truth. In a hybrid app, this means ensuring that your legacy ViewControllers or Activities and your new components both observe the same underlying data stream. You can no longer afford to have local copies of data living inside UI components that are not synced with the global state.
By centralizing state management, you eliminate the need for complex callback chains that were common in XML and Storyboard layouts. Modern reactive patterns like StateFlow in Kotlin or Published properties in Swift provide the necessary plumbing to make this possible. This architectural shift is the first step toward a successful incremental migration.
Incremental Adoption on Android
Replacing a massive XML codebase with Jetpack Compose is a marathon rather than a sprint. Google provided the ComposeView as the primary bridge for developers who want to introduce declarative components into existing view hierarchies. This allows you to place a Compose-based widget inside a traditional layout file or even programmatically add it to a ViewGroup.
One common scenario is adding a new feature to an existing screen that is still defined in XML. You can define a placeholder in your XML file and then bind a Composable function to that placeholder within your Activity or Fragment. This approach minimizes risk by isolating new code within a controlled environment while maintaining the rest of the screen's stability.
1// Inside your Fragment's onCreateView or onViewCreated
2val composeView = view.findViewById<ComposeView>(R.id.compose_header_container)
3composeView.apply {
4 // Ensure the composition is disposed of when the view lifecycle is destroyed
5 setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
6
7 setContent {
8 // Modern theme wrapper for consistency
9 AppCustomTheme {
10 UserHeaderComponent(
11 userName = viewModel.currentUserName,
12 onProfileClick = { navigateToProfile() }
13 )
14 }
15 }
16}When moving in the opposite direction, you might encounter complex legacy views like MapView or specialized third-party charting libraries that do not yet have a native Compose implementation. In these cases, you use the AndroidView composable. This acts as a wrapper that allows you to instantiate and manage a traditional View inside a declarative layout function.
Managing Lifecycle and Composition
One of the most frequent pitfalls during Android migration is ignoring the view composition strategy. Because Compose lives inside a traditional View, it needs to know exactly when to clean up its resources to avoid memory leaks. By default, Compose might not know when a Fragment view is destroyed versus when the Fragment itself is removed.
Using the DisposeOnViewTreeLifecycleDestroyed strategy ensures that the UI resources are freed correctly. It is also important to remember that state hoisting should happen at the boundary between XML and Compose. Pass the necessary data down as simple parameters and pass events up as lambda expressions to keep the Composable pure.
Bridging the Gap in iOS Development
On the iOS side, Apple introduced UIHostingController as the gateway for embedding SwiftUI views into UIKit architectures. This specialized view controller acts as a container that takes any SwiftUI view and makes it behave like a standard UIViewController. This is particularly useful for migrating complex apps where the navigation logic is still handled by a UINavigationController or a Coordinator pattern.
When you wrap a SwiftUI view in a hosting controller, you can push it onto a navigation stack or present it modally just like any other controller. This flexibility allows teams to build all new features in SwiftUI while leaving the core infrastructure of the app untouched. It is a powerful way to prove the value of the new framework without a full rewrite.
1func showProductDetails(productId: String) {
2 // Initialize the domain state
3 let viewModel = ProductDetailViewModel(productId: productId)
4
5 // Wrap the SwiftUI view in a hosting controller
6 let detailView = ProductDetailView(viewModel: viewModel)
7 let hostingController = UIHostingController(rootView: detailView)
8
9 // Use standard UIKit navigation methods
10 hostingController.title = "Product Details"
11 navigationController.pushViewController(hostingController, animated: true)
12}For scenarios where you need to use a UIKit component inside a SwiftUI hierarchy, you must implement the UIViewRepresentable protocol. This requires you to define a make method to create the view and an update method to synchronize it with SwiftUI state changes. This is often the bridge used for things like WKWebView or complex text editors that require fine-grained delegate control.
The Coordinator Pattern in UIViewRepresentable
Handling events from a UIKit view inside SwiftUI requires an internal class known as a Coordinator. The Coordinator acts as a bridge between the imperative delegate methods of the UIKit view and the reactive bindings of SwiftUI. Without this pattern, you would struggle to capture user input or lifecycle events from legacy components.
A common mistake is trying to manage state directly inside the makeUIView function. Instead, you should always treat the updateUIView function as the primary place for data synchronization. This ensures that every time the SwiftUI state changes, the wrapped UIKit view is updated to reflect the new reality.
Architecting for Hybrid State Management
The most difficult part of an incremental migration is not the UI itself, but managing the data flow between two different architectural paradigms. Legacy apps often use patterns like MVP or MVVM with heavy reliance on mutable objects. Modern declarative frameworks prefer immutable state and unidirectional data flow, which can create friction when the two worlds meet.
To bridge this gap, you should aim to wrap your legacy data sources in reactive streams as soon as possible. On Android, this might involve converting repository callbacks into Kotlin Flows. On iOS, you might wrap an existing delegate-based service into a Combine publisher or an AsyncSequence.
- Avoid duplicating state variables across the interoperability bridge to prevent synchronization lag.
- Use ViewModel objects that are shared between the legacy container and the new declarative component.
- Ensure that thread safety is maintained when legacy background tasks attempt to update declarative state UI.
- Prefer passing primitive data or simple data transfer objects across the bridge rather than complex domain entities.
When a SwiftUI view and a UIKit view share a ViewModel, the ViewModel must conform to the ObservableObject protocol. This allows the SwiftUI view to automatically subscribe to changes while the UIKit view can still manually observe the same properties. This shared visibility is the secret to making a hybrid app feel like a cohesive experience to the end user.
Performance and Production Safety
While interoperability layers are powerful, they come with a performance cost that must be monitored. Every time you cross the bridge between a declarative framework and a legacy view system, there is a small amount of overhead. If you have hundreds of these bridges within a single scrollable list, you may start to see dropped frames or high CPU usage.
On Android, avoid nesting too many ComposeViews inside a RecyclerView if each Composable is very complex. Instead, try to convert the entire list item to Compose at once. On iOS, be mindful of how often your updateUIView method is called, as frequent updates can trigger expensive layout passes in UIKit that degrade the user experience.
Measure twice and bridge once. Over-using interoperability layers can lead to a fragmented architecture that is harder to maintain than the original legacy code.
Finally, ensure that your design system is unified across both frameworks. A common mistake is updating the color palette in the new declarative code while forgetting to update the XML styles or Storyboard assets. Maintaining a centralized theme configuration that both systems can read from will prevent visual regressions during the transition period.
Testing Hybrid Interfaces
Testing a hybrid application requires a combination of traditional UI testing tools and new framework-specific testing libraries. On Android, you can use the ComposeTestRule alongside Espresso to interact with both types of views in a single test case. This allows you to verify that an action in a Composable correctly updates a legacy XML view.
For iOS, XCUITest remains the standard for black-box testing of hybrid apps, as it interacts with the rendered accessibility tree rather than the underlying framework. However, you should supplement this with snapshot testing for your new SwiftUI components. This ensures that the visual integrity is maintained even as you continue to refactor the surrounding UIKit infrastructure.
