Framework
An Android screen management library that eliminates Fragment boilerplate, ViewModel wiring, and back-stack plumbing — so you can focus entirely on business logic.
No more Fragment transactions
Navigation is a one-liner. Type-safe, with no string tags or resource IDs.
No more ViewModel factories
Every screen gets a scoped, rotation-safe coordinator automatically.
No more onSaveInstanceState
State lives in SavedStateHandle and binds to views declaratively.
No more callback wiring
Result propagation between any two screens — including dialogs — is built in.
Dialogs are just Screens
Present dialogs and bottom sheets through the same navigator interface.
Per-screen back handling
Override back press in any screen without touching the Activity.
How it works
Framework wraps Android's Fragment system with three core primitives:
- FrameworkActivity — your single host. Owns the screen registry and implements Navigator.
- Screen — your unit of UI. Owns its own state, lifecycle, and navigation context.
- Navigator — the interface every Screen uses to move around the app.
Under the hood, each Screen is hosted inside a ScreenHostFragment managed by a ScreenCoordinator ViewModel. You never interact with either directly.
Setup & Installation
Framework is distributed via JitPack. Add the repository and the dependency — that's it.
1. Add JitPack
Add JitPack to your dependency resolution in settings.gradle or settings.gradle.kts:
// settings.gradle
dependencyResolutionManagement {
repositories {
maven { url 'https://jitpack.io' }
}
}groovy
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
maven("https://jitpack.io")
}
}kotlin
2. Add the dependency
// app/build.gradle
dependencies {
implementation 'com.github.AyazAlvi:framework:<version>'
}groovy
// app/build.gradle.kts
dependencies {
implementation("com.github.AyazAlvi:framework:<version>")
}kotlin
Requirements
- Android API 21+
- Kotlin 1.8+
- ViewBinding enabled in your module
- AndroidX AppCompat and Fragment KTX
Enable ViewBinding
android {
buildFeatures {
viewBinding true
}
}groovy
android {
buildFeatures {
viewBinding = true
}
}kotlin
FrameworkActivity
The single host Activity for your entire app. It owns the screen registry, implements Navigator, and manages the Fragment back stack.
Basic setup
Extend FrameworkActivity and implement two things: your container ID, and screen registration.
class MainActivity : FrameworkActivity() {
override val fragmentContainerId = R.id.container
override fun onRegisterScreens(registry: ScreenRegistry) {
registry.register(FragmentHomeBinding::inflate, ::HomeScreen)
registry.register(FragmentDetailBinding::inflate, ::DetailScreen)
registry.register(DialogConfirmBinding::inflate, ::ConfirmDialog)
registry.register(SheetOptionsBinding::inflate, ::OptionsSheet)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (savedInstanceState == null) {
push<HomeScreen>()
}
}
}kotlin
Layout
Your layout just needs a container for screens. A FrameLayout works perfectly:
<FrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />xml
Multiple containers
If your layout has multiple containers (e.g. a side panel on tablets), you can push screens into any of them by passing containerId:
navigator.push<DetailScreen>(containerId = R.id.panel_container)kotlin
Transitions
Framework automatically reads activityOpenEnterAnimation, activityOpenExitAnimation, and their close variants from your app theme. Define them in your theme to get free transition animations on every navigation:
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight">
<item name="android:activityOpenEnterAnimation">@anim/slide_in_right</item>
<item name="android:activityOpenExitAnimation">@anim/slide_out_left</item>
<item name="android:activityCloseEnterAnimation">@anim/slide_in_left</item>
<item name="android:activityCloseExitAnimation">@anim/slide_out_right</item>
</style>xml
Screen
The fundamental unit of UI in Framework. A Screen owns its ViewBinding, state, and navigation context. It is not a Fragment — it simply lives inside one.
Creating a Screen
class HomeScreen(context: ScreenContext) : Screen<FragmentHomeBinding>(context) {
override fun onFirstLaunch() {
// Called once — survives rotation. Put initialization logic here.
}
override fun onUI() {
// Called every time the view is (re)created.
// Wire clicks, bind state, set up adapters here.
ui.btnNext.setOnClickListener {
navigator.push<DetailScreen>()
}
}
}kotlin
onFirstLaunch vs onUI
| Method | When it runs | Use it for |
|---|---|---|
| onFirstLaunch() | Once per Screen lifetime | Fetching data, setting initial state, one-time setup |
| onUI() | Every view creation | Binding state, setting click listeners, configuring views |
onFirstLaunch() will not re-run on rotation. onUI() will, but state bound through state() is automatically re-applied to the new views.
Accessing the UI
Use the ui property anywhere inside your Screen to access your ViewBinding. It is only valid while the view is attached — accessing it outside of that window throws an IllegalStateException.
ui.tvTitle.text = "Hello"
ui.btnSave.setOnClickListener { save() }kotlin
Accessing context
// Access the host Activity (typed as FrameworkActivity)
val act = activity
// Access the host Fragment (for lifecycle owners, etc.)
val frag = fragmentkotlin
Screen Registry
The registry is how Framework knows which ViewBinding inflater and which constructor to use for each Screen class. Every screen that will ever be shown must be registered.
Registering screens
override fun onRegisterScreens(registry: ScreenRegistry) {
// Short form — works when factory is a simple constructor ref
registry.register(FragmentHomeBinding::inflate, ::HomeScreen)
// Long form — useful if you need to inject extra dependencies
registry.register(
inflateBlock = FragmentDetailBinding::inflate,
factory = { context -> DetailScreen(context, myDependency) }
)
}kotlin
What happens at registration
The registry stores a ScreenDefinition per class — a pair of the inflate function and the factory. When navigation triggers a screen, Framework looks up the definition, creates the binding, and calls the factory with a ScreenContext.
IllegalStateException at runtime.
ScreenDefinition
| Field | Type | Description |
|---|---|---|
| inflate | (LayoutInflater, ViewGroup?) → ViewBinding | The binding inflater from your generated binding class |
| factory | (ScreenContext) → Screen<*> | Constructs your Screen with the provided context |
Dialogs & Bottom Sheets
Dialogs and bottom sheets are first-class screens. Register them exactly like any other screen and present them through the navigator.
Registering
registry.register(DialogConfirmBinding::inflate, ::ConfirmDialog)
registry.register(SheetOptionsBinding::inflate, ::OptionsSheet)kotlin
Presenting
navigator.presentDialog<ConfirmDialog>()
navigator.presentDialog<ConfirmDialog>(args = bundleOf("message" to "Are you sure?"))
navigator.presentBottomSheet<OptionsSheet>()
navigator.presentBottomSheet<OptionsSheet>(args = bundleOf("title" to "Pick one"))kotlin
Implementing a dialog Screen
class ConfirmDialog(context: ScreenContext) : Screen<DialogConfirmBinding>(context) {
override fun onUI() {
val message = arguments?.getString("message") ?: "Are you sure?"
ui.tvMessage.text = message
ui.btnCancel.setOnClickListener { close() }
ui.btnConfirm.setOnClickListener {
popWithResult("confirm", bundleOf("confirmed" to true))
}
}
}kotlin
Dismissing
Call close() from inside the screen — it automatically detects whether it's hosted in a dialog, bottom sheet, or regular fragment and dismisses correctly. Or dismiss explicitly from outside:
navigator.dismissCurrentDialog()
navigator.dismissCurrentBottomSheet()kotlin
close() inside dialog screens. Calling navigator.pop() from a dialog screen will not dismiss it — it will attempt to pop the fragment back stack instead. close() is host-aware.
Result Propagation
Pass data back from any screen — fragment, dialog, or bottom sheet — to its caller with a consistent API built on FragmentResultListener.
Push and listen
// From a parent Screen — push and set up the listener in one call
pushForResult<DetailScreen>(requestKey = "detail_result") { bundle ->
val name = bundle.getString("name")
ui.tvName.text = name
}kotlin
Send result and close
// From the child Screen
ui.btnSave.setOnClickListener {
popWithResult(
requestKey = "detail_result",
result = bundleOf("name" to ui.etName.text.toString())
)
}kotlin
With dialogs and bottom sheets
// Present a dialog and listen for its result
presentDialogForResult<ConfirmDialog>(requestKey = "confirm") { bundle ->
if (bundle.getBoolean("confirmed")) deleteItem()
}
// Present a bottom sheet and listen for its result
presentBottomSheetForResult<OptionsSheet>(requestKey = "option_picked") { bundle ->
val option = bundle.getString("option")
}kotlin
Manual API
If you need more control:
// Set a result without closing
setResult("my_key", bundleOf("data" to "value"))
// Listen for a result manually
listenForResult("my_key") { bundle -> /* handle */ }kotlin
API summary
| Method | Where | Description |
|---|---|---|
| pushForResult<S>(key, args?, onResult) | Parent screen | Push + register listener atomically |
| presentDialogForResult<S>(key, args?, onResult) | Parent screen | Present dialog + register listener |
| presentBottomSheetForResult<S>(key, args?, onResult) | Parent screen | Present sheet + register listener |
| popWithResult(key, bundle) | Child screen | Set result then close |
| setResult(key, bundle) | Any screen | Set result without closing |
| listenForResult(key, onResult) | Any screen | Register a listener manually |
Back Press Handling
Override the back button for any individual screen without subclassing the Activity or managing OnBackPressedCallback yourself.
Enabling the override
class EditorScreen(context: ScreenContext) : Screen<FragmentEditorBinding>(context) {
override fun onFirstLaunch() {
backPressOverrideEnabled = true
}
override fun onBackPressed() {
if (hasUnsavedChanges()) {
presentDialog<DiscardDialog>()
} else {
navigator.performDefaultBack()
}
}
}kotlin
Enabling and disabling dynamically
You can toggle the override at any point — for example, only block back when a form is dirty:
ui.etContent.addTextChangedListener {
backPressOverrideEnabled = it.toString().isNotEmpty()
}kotlin
performDefaultBack
Always call navigator.performDefaultBack() rather than navigator.pop() inside your override. It correctly handles the Activity finish case when the back stack is empty.
backPressOverrideEnabled = true on a dialog or bottom sheet screen also sets isCancelable = false, preventing dismissal on outside tap.
State Management
State is stored in SavedStateHandle and survives both rotation and process death. Bind state keys to views — updates automatically dispatch to the main thread.
Declaring state
class ProfileScreen(context: ScreenContext) : Screen<FragmentProfileBinding>(context) {
private val username by lazy { state("username", "") }
private val isLoading by lazy { state("loading", false) }
private val count by lazy { state("count", 0) }
}kotlin
Binding to views
override fun onUI() {
username.bind(ui.tvName) { value, view ->
view.text = value
}
isLoading.bind(ui.progressBar) { value, view ->
view.visibility = if (value) View.VISIBLE else View.GONE
}
}kotlin
Updating state
// Direct assignment — triggers all bound views immediately
username.value = "Ayaz"
isLoading.value = true
count.value++
// Safe from background threads too
viewModelScope.launch(Dispatchers.IO) {
val data = repo.fetch()
username.value = data.name // dispatches to main thread automatically
}kotlin
Reading state
val currentName = username.value
val loading = isLoading.valuekotlin
by lazy. State is initialized lazily — declaring it at the top of your Screen with by lazy ensures the initial value is only written once, even after rotation. Without lazy, the initial value would overwrite the restored value on rotation.
How it works
Each ScreenState delegates to a key in the Screen's stateDataRegistry, which is kept in sync with SavedStateHandle on every write. When onUI() runs after rotation, all existing state is immediately re-applied to newly bound views.
Arguments
Pass data into a Screen at push time via a Bundle. Access it through the arguments property.
Passing arguments
navigator.push<DetailScreen>(
args = bundleOf(
"userId" to "abc123",
"position" to 5,
"isEditable" to true
)
)kotlin
Receiving arguments
class DetailScreen(context: ScreenContext) : Screen<FragmentDetailBinding>(context) {
override fun onFirstLaunch() {
val userId = arguments?.getString("userId")
val position = arguments?.getInt("position", 0)
val isEditable = arguments?.getBoolean("isEditable", false)
// Load data using these args
loadUser(userId)
}
}kotlin
onFirstLaunch(), not onUI(). Arguments are immutable input — read them once and store the result in state if you need to display it.
Shared ViewModels
Share a single ViewModel instance across multiple screens in the same Activity using sharedViewModel().
Defining a shared ViewModel
class AppViewModel : ViewModel() {
val cart = MutableLiveData<List<Item>>(emptyList())
fun addItem(item: Item) {
cart.value = (cart.value ?: emptyList()) + item
}
}kotlin
Accessing from multiple screens
class ProductScreen(context: ScreenContext) : Screen<FragmentProductBinding>(context) {
private val vm: AppViewModel by lazy { sharedViewModel() }
override fun onUI() {
ui.btnAdd.setOnClickListener { vm.addItem(currentItem) }
}
}
class CartScreen(context: ScreenContext) : Screen<FragmentCartBinding>(context) {
private val vm: AppViewModel by lazy { sharedViewModel() }
override fun onUI() {
vm.cart.observe(fragment.viewLifecycleOwner) { items ->
adapter.submitList(items)
}
}
}kotlin
sharedViewModel() uses ViewModelProvider(activity) — the same instance is returned for the same ViewModel class across all screens in the same Activity. It is cleared when the Activity is destroyed.
bundleOf
A type-safe Bundle builder included with Framework. Covers all standard types with a clean vararg pair syntax.
Usage
val bundle = bundleOf(
"id" to 42,
"name" to "Ayaz",
"active" to true,
"score" to 9.5f,
"tags" to arrayOf("android", "kotlin"),
"nullableField" to null // removes the key
)kotlin
Supported types
| Type | Notes |
|---|---|
| String, CharSequence | |
| Int, Long, Float, Double, Short, Byte, Char, Boolean | All primitives |
| IntArray, LongArray, FloatArray, BooleanArray, ByteArray, ShortArray, CharArray | Primitive arrays |
| Array<String>, Array<Parcelable> | Object arrays |
| Parcelable | Any Parcelable object |
| Serializable | Fallback for Serializable objects |
| null | Removes the key from the Bundle |
IllegalArgumentException at runtime. For custom objects, implement Parcelable (preferred) or Serializable.
Lifecycle Reference
Understanding when each Screen method is called helps you put logic in the right place.
Screen lifecycle
| Event | Screen method | Fragment equivalent |
|---|---|---|
| First creation (pre-view) | onFirstLaunch() | onCreate() — first time only |
| View created | onUI() | onViewCreated() |
| View destroyed | — | onDestroyView() |
| Screen fully destroyed | onCleared() | onDestroy() + ViewModel.onCleared() |
State restoration sequence
- Host Fragment is recreated after rotation
ScreenCoordinatorViewModel is retrieved — same instance, so Screen is retainedonFirstLaunch()does not run again- New view is inflated,
onUI()runs - All state keys in
stateDataRegistryare re-applied to the new view bindings immediately
UI availability
The ui property is only valid between onUI() and the next onDestroyView(). Accessing it outside this window throws IllegalStateException. When updating state from background threads, the binding is checked for null before dispatching — safe to call at any time.
Full API Reference
Screen<VB>
| Member | Type | Description |
|---|---|---|
| ui | VB | The current ViewBinding. Only valid while view is attached. |
| navigator | Navigator | Navigation interface for this screen. |
| arguments | Bundle? | Arguments passed at push time. |
| savedStateHandle | SavedStateHandle | Direct access to the state handle. |
| activity | FrameworkActivity | The host Activity. |
| fragment | Fragment | The host Fragment. |
| backPressOverrideEnabled | Boolean | Enable/disable custom back handling. |
| onFirstLaunch() | open fun | Called once on first creation. |
| onUI() | abstract fun | Called every time the view is created. |
| onBackPressed() | open fun | Override for custom back behavior. |
| state(key, initialValue) | ScreenState<T> | Declare a piece of state. |
| close() | fun | Smart close — detects dialog/sheet/fragment. |
| setResult(key, bundle) | fun | Emit a result without closing. |
| popWithResult(key, bundle) | fun | Emit a result and close. |
| listenForResult(key, onResult) | fun | Subscribe to a result. |
| sharedViewModel<VM>() | fun | Get or create an Activity-scoped ViewModel. |
ScreenState<T>
| Member | Description |
|---|---|
| value: T | Get or set the current value. Setting triggers all bound views. |
| bind(view, bindingBlock) | Bind this state to a view. Re-applied automatically on view recreation. |
Navigator (interface)
| Method | Description |
|---|---|
| push<S>(args?, sharedElement?, containerId?) | Push screen onto back stack. |
| replace<S>(args?, containerId?) | Replace current screen, no back stack entry. |
| pop() | Pop back stack or finish Activity. |
| popToRoot() | Pop all entries back to first screen. |
| performDefaultBack() | Execute default back behavior. |
| presentDialog<S>(args?) | Show a DialogFragment-hosted screen. |
| presentBottomSheet<S>(args?) | Show a BottomSheetDialogFragment-hosted screen. |
| dismissCurrentDialog() | Dismiss the topmost dialog. |
| dismissCurrentBottomSheet() | Dismiss the topmost bottom sheet. |
ScreenRegistry
| Method | Description |
|---|---|
| register<S>(inflateBlock, factory) | Register a Screen with its binding inflater and constructor. |
Top-level extensions on Screen
| Function | Description |
|---|---|
| pushForResult<S>(key, args?, onResult) | Push + register result listener atomically. |
| presentDialogForResult<S>(key, args?, onResult) | Present dialog + register result listener. |
| presentBottomSheetForResult<S>(key, args?, onResult) | Present sheet + register result listener. |
| state(key, initialValue) | Declare a ScreenState on this Screen. |
| sharedViewModel<VM>() | Get an Activity-scoped ViewModel instance. |
bundleOf
| Signature | Description |
|---|---|
| bundleOf(vararg pairs: Pair<String, Any?>): Bundle | Type-safe Bundle builder. Null values remove the key. |