From 116e1881727d3885971a7c0570d8855370bb6649 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Wed, 1 May 2024 20:33:30 +1200 Subject: [PATCH] Update README.md --- README.md | 407 ++++++++++++++---------------------------------------- 1 file changed, 104 insertions(+), 303 deletions(-) diff --git a/README.md b/README.md index b1d20754..2383916d 100644 --- a/README.md +++ b/README.md @@ -3,361 +3,162 @@ > > Please see the [CHANGELOG](./CHANGELOG.md) to understand the latest changes in Enro - # Enro 🗺️ +### [enro.dev](https://enro.dev) -A simple navigation library for Android +Enro is a powerful navigation library based on a simple idea; screens within an application should behave like functions. *"The novices’ eyes followed the wriggling path up from the well as it swept a great meandering arc around the hillside. Its stones were green with moss and beset with weeds. Where the path disappeared through the gate they noticed that it joined a second track of bare earth, where the grass appeared to have been trampled so often that it ceased to grow. The dusty track ran straight from the gate to the well, marred only by a fresh set of sandal-prints that went down, and then up, and ended at the feet of the young monk who had fetched their water." - [The Garden Path](http://thecodelesscode.com/case/156)* -## Features - -- Navigate between Fragments, Activities and @Composables seamlessly - -- Describe navigation destinations through annotations or a simple DSL - -- Remove navigation logic from screens implemented as Fragments, Activities or @Composables - -- Pass type-safe results between screens across configuration changes and process death - -- Create beautiful transitions between specific destinations - -## Using Enro -#### Gradle -Enro is published to [Maven Central](https://search.maven.org/). Make sure your project includes the `mavenCentral()` repository, and then include the following in your module's build.gradle: +### Gradle quick-start +Enro is published to [Maven Central](https://search.maven.org/). Make sure your project includes the mavenCentral() repository, and then include the following in your module's build.gradle: -```gradle +```kotlin dependencies { - implementation "dev.enro:enro:2.3.0" - kapt "dev.enro:enro-processor:2.3.0" + implementation("dev.enro:enro:2.4.0") + ksp("dev.enro:enro-processor:2.4.0") // both kapt and ksp are supported + testImplementation("dev.enro:enro-test:2.4.0") } ``` -#### 1. Define your NavigationKeys +# Introduction +This introduction is designed to give a brief overview of how Enro works. It doesn't contain all the information you might need to know to get Enro installed in an application, or provide specific details about each of the topics covered. For this information please refer to the other documentation, such as: +* [Installing Enro](https://enro.dev/docs/installing-enro.html) +* [Navigation Keys](https://enro.dev/docs/navigation-keys.html) +* [FAQ](https://enro.dev/docs/frequently-asked-questions.html) + +## NavigationKeys +Building a screen using Enro begins with defining a `NavigationKey`. A `NavigationKey` can be thought of like the function signature or interface for a screen. Just like a function signature, a `NavigationKey` represents a contract. By invoking the contract, and providing the requested parameters, an action will occur and you may (or may not) receive a result. +Here's an example of two `NavigationKey`s that you might find in an Enro application: ```kotlin -@Parcelize -data class MyListKey(val listType: String) : NavigationKey.SupportsPush @Parcelize -data class MyDetailKey(val itemId: String, val isReadOnly) : NavigationKey.SupportsPush +data class ShowUserProfile( + val userId: UserId +) : NavigationKey.SupportsPush @Parcelize -data class MyComposeKey(val name: String) : NavigationKey.SupportsPresent +data class SelectDate( + val minDate: LocalDate? = null, + val maxDate: LocalDate? = null, +) : NavigationKey.SupportsPresent.WithResult -@Parcelize -data class MyResultKey(val query: String) : NavigationKey.SupportsPresent.WithResult ``` -#### 2. Define your NavigationDestinations +If you think of the `NavigationKey`s as function signatures, they could look something like this: ```kotlin -@NavigationDestination(MyListKey::class) -class ListFragment : Fragment() -@NavigationDestination(MyDetailKey::class) -class DetailActivity : AppCompatActivity() +fun showUserProfile(userId: UserId): Unit +fun selectDate(minDate: LocalDate? = null, maxDate: LocalDate? = null): LocalDate -@Composable -@NavigationDestination(MyComposeKey::class) -fun MyComposableScreen() { } ``` -#### 3. Annotate your Application as a NavigationComponent, and implement the NavigationApplication interface -```kotlin -@NavigationComponent -class MyApplication : Application(), NavigationApplication { - override val navigationController = navigationController() -} -``` +## NavigationHandles +Once you've defined the `NavigationKey` for a screen, you'll want to use it. In any Activity, Fragment or Composable, you will be able to get access to a `NavigationHandle`, which allows you to perform navigation. The syntax is slightly different for each type of screen. -#### 4. Navigate! +### In a Fragment or Activity: ```kotlin -@NavigationDestination(MyListKey::class) -class ListFragment : ListFragment() { - val navigation by navigationHandle() - - fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val listType = navigation.key.listType - view.findViewById(R.id.list_title_text).text = "List: $listType" - } - - fun onListItemSelected(selectedId: String) { - val key = MyDetailKey(itemId = selectedId) - navigation.push(key) - } -} -@Composable -@NavigationDestination(MyComposeKey::class) -fun MyComposableScreen() { - val navigation = navigationHandle() - - Button( - content = { Text("Hello, ${navigation.key}") }, - onClick = { - navigation.push(MyListKey(...)) - } - ) +class ExampleFragment : Fragment() { + val selectDate by registerForNavigationResult { selectedDate: LocalDate -> + /* do something! */ + } + + fun onSelectDateButtonPressed() = selectDate.present( + SelectDate(maxDate = LocalDate.now()) + ) + + fun onProfileButtonPressed() { + getNavigationHandle().push( + ShowUserProfile(userId = /* ... */) + ) + } } ``` -## Applications using Enro -

- - - -   -   - - - -

- -## FAQ -#### Minimum SDK Version - -Enro supports a minimum SDK version of 21 - -#### How well does Enro work alongside "normal" Android Activity/Fragment navigation? -Enro is designed to integrate well with Android's default navigation. It's easy to manually open a Fragment or Activity as if Enro itself had performed the navigation. Create a NavigationInstruction object that represents the navigation, and then add it to the arguments of a Fragment, or the Intent for an Activity, and then open the Fragment/Activity as you normally would. - -Example: -```kotlin -val instruction = NavigationInstruction.Present( - navigationKey = MyNavigationKey(... -) -) -val intent = Intent(this, MyActivity::class).addOpenInstruction(instruction) -startActivity(intent) -``` - -#### How does Enro decide if a Fragment, or the Activity should receive a back button press? - -Enro considers the primaryNavigationFragment to be the "active" navigation target, or the current -Activity if there is no primaryNavigationFragment. In a nested Fragment situation, the -primaryNavigationFragment of the primaryNavigationFragment of the ... is considered "active". - -#### What kind of navigation instructions does Enro support? - -Enro supports three navigation instructions: `push`, `present` and `replaceRoot`. - -#### How does Enro support Activities navigating to Fragments? - -When an Activity executes a navigation instruction that resolves to a Fragment, one of three things -will happen: - -1. If the instruction is a "push", and the Activity defines a "navigationContainer" that accepts the Fragment's type the Fragment will be opened into the container view defined by that container. -2. If the instruction is a "present", or the Activity **does not** define a navigationContainer that acccepts the Fragment's type the Fragment will be opened into a either a floating, full window dialog, or a full screen Activity (depending on the situation). -3. If the instruction is a "replaceRoot" the Fragment will be opened in a full screen Activity - -#### How does Enro support Activities and Fragments navigating to @Composables? - -When an Activity or Fragment executes a navigation instruction that resolves to a @Composable, one of three things will happen: - -1. If the instruction is a "push", and the Activity/Fragment defines a "navigationContainer" that accepts the @Composable's type the @Composable will be opened into the container view defined by that container. -2. If the instruction is a "present", or the Activity/Fragment **does not** define a navigationContainer that acccepts the @Composable's type the @Composable will be opened into a either a floating, full window dialog, or a full screen Activity (depending on the situation). -3. If the instruction is a "replaceRoot" the @Composable will be opened in a full screen Activity - -#### How do I deal with passing results between screens? - -Enro supports any NavigationKey/NavigationDestination providing a result. Instead of implementing the NavigationKey interface on the NavigationKey that provides the result, implement NavigationKey..WithResult where T is the type of the result. Once you're ready to navigate to that NavigationKey and consume a result, you'll want to call "registerForNavigationResult" in your Fragment/Activity/ViewModel. This API is very similar to the AndroidX Activity 1.2.0 ActivityResultLauncher. - -Example: - +### In a Composable: ```kotlin -@Parcelize -class RequestDataKey(...) : NavigationKey.WithResult() - -@NavigationDestination(RequestDataKey::class) -class MyResultActivity : AppCompatActivity() { - val navigation by navigationHandle() - fun onSendResultButtonClicked() { - navigation.closeWithResult(false) - } +@Composable +fun ExampleComposable() { + val navigation = navigationHandle() + val selectDate = registerForNavigationResult { selectedDate: LocalDate -> + /* do something! */ + } + + Button(onClick = { + selectDate.present( + SelectDate(maxDate = LocalDate.now()) + ) + }) { /* ... */ } + + Button(onClick = { + navigation.push( + ShowUserProfile(userId = /* ... */) + ) + }) { /* ... */ } } -@NavigationDestination(...) -class MyActivity : AppCompatActivity() { - val requestData by registerForNavigationResult { - // do something! - } - - fun onRequestDataButtonClicked() { - requestData.open(RequestDataKey(/*arguments*/)) - } -} ``` -#### How do I do List/Detail navigation -Enro has a built in component for this. If you want to build something more complex than what the built-in component provides, you'll be able to use the built-in component as a reference/starting point, as it is built purely on Enro's public API - -#### How do I handle multiple backstacks on each page of a BottomNavigationView? - -Each `navigationContainer` has it's own backstack. The suggested implementation is to create one `navigationContainer` for each +## NavigationDestinations +You might have noticed that we've defined our `ExampleFragment` and `ExampleComposable` in the example above before we've even begun to think about how we're going to implement the `ShowUserProfile` and `SelectDate` destinations. That's because implementing a `NavigationDestination` in Enro is the least interesting part of the process. All you need to do to make this application complete is to build an Activity, Fragment or Composable, and mark it as the `NavigationDestination` for a particular `NavigationKey`. -#### I'd like to do shared element transitions, or do something special when navigating between certain screens -Enro allows you to define "NavigationExecutors" as overrides for the default behaviour, which handle these situations. +The recommended approach to mark an Activity, Fragment or Composable as a `NavigationDestination` is to use the Enro annotation processor and the `@NavigationDestination` annotation. -There will be an example project that shows how this all works in the future, but for now, here's a basic explanation: -1. A NavigationExecutor is typed for a "From", an "Opens", and a NavigationKey type. -2. Enro performs navigation on a "NavigationContext", which is basically either a Fragment or a FragmentActivity -3. A NavigationExecutor defines two methods - * `open`, which takes a NavigationContext of the "From" type, a NavigationBinding for the "Opens" type, and a NavigationInstruction (i.e. the From context is attempting to open the NavigationBinding with the input NavigationInstruction) - * `close`, which takes a NavigationContext of the "Opens" type (i.e. you're closing what you've already opened) -4. By creating a NavigationExecutor between two specific screens and registering this with the NavigationController, you're able to override the default navigation behaviour (although you're still able to call back to the DefaultActivityExecutor or DefaultFragmentExecutor if you need to) -5. See the method in NavigationControllerBuilder for `override` -6. When a NavigationContext decides what NavigationExecutor to execute an instruction on, Enro will look at the NavigationContext originating the NavigationInstruction and then walk up toward's it's root NavigationContext (i.e. a Fragment will check itself, then its parent Fragment, and then that parent Fragment's Activity), checking for an appropriate override along the way. If it finds no override, the default will be used. NavigationContexts that are the children of the current NavigationContext will not be searched, only the parents. - -Example: +### In a Fragment or Activity: ```kotlin -// This override will place the "DetailFragment" into the container R.id.detail, -// and when it's closed, will set whatever Fragment is in the R.id.master container as the primary navigation fragment -override( - launch = { - val fragment = DetailFragment().addOpenInstruction(it.instruction) - it.fromContext.childFragmentManager.beginTransaction() - .replace(R.id.detail, fragment) - .setPrimaryNavigationFragment(fragment) - .commitNow() - }, - close = { context -> - context.fragment.parentFragmentManager.beginTransaction() - .remove(context.fragment) - .setPrimaryNavigationFragment(context.parentActivity.supportFragmentManager.findFragmentById(R.id.master)) - .commitNow() - } -) -``` - -#### I'd like to add a custom animation (using an override) for a @Composable @NavigationDestination -Unlike Activities and Fragments, when you want to write an override for a @Composable @NavigationDestination (particularly to specify custom animations), you don't have a class to reference in the To or From type arguments to the `override()` function. At first glance, it may appear that it is not possible to create an override for a @Composable @NavigationDestination. -However, when you define a @Composable @NavigationDestination, Enro generates a class, called `Destination`. This class can be used when specifying overrides for @Composable @NavigationDestinations. - -Example: -```kotlin -val navigationController = navigationController { - /** - * This example assumes you have a @Composable function that is also a @NavigationDestination, and that the name - * of the @Composable function is `MyComposableScreen`. - * - * This example will set both the open and close animations for this screen to be the default "no animation" animation - * that Enro provides. - */ - override { - animation { DefaultAnimations.none } - closeAnimation { DefaultAnimations.none } - } +@NavigationDestination(ShowUserProfile::class) +class ProfileFragment : Fragment { + // providing a type to `by navigationHandle()` gives you access to the NavigationKey + // used to open this destination, and you can use this to read the + // arguments for the destination + val navigation by navigationHandle() } -``` - -Please note, that the `Destination` is a generated class, and will not be available until you've compiled the project at least once since defining your @Composable @NavigationDestination (similar to how Dagger generates Components). - -#### My Activity crashes on launch, what's going on?! -It's possible for an Activity to be launched from multiple places. Most of these can be controlled by Enro, but some of them cannot. For example, an Activity that's declared in the manifest as a MAIN/LAUNCHER Activity might be launched by the Android operating system when the user opens your application for the first time. Because Enro hasn't launched the Activity, it's not going to know what the NavigationKey for that Activity is, and won't be able to read it from the Activity's intent. - -Luckily, there's an easy solution! When you declare an Activty or Fragment, you are able to do a small amount of configuration inside the `navigationHandle` block using the `defaultKey` method. This method takes a `NavigationKey` as an argument, and if the Fragment or Activity is opened without being passed a `NavigationKey` as part of its arguments, the value passed will be treated as the `NavigationKey`. This could occur because of an Activity being launched via a MAIN/LAUNCHER intent filter, via a standard `Intent`, or via a `Fragment` being added directly to a `FragmentManager` without any `NavigationInstruction` being applied. In other words, any situation where Enro is not used to launch the Activity or Fragment. -Example: -```kotlin -@Parcelize -class MainKey(isDefaultKey: Boolean = false) : NavigationKey - -@NavigationDestination(MainKey::class) -class MainActivity : AppCompatActivity() { - private val navigation by navigationHandle { - defaultKey( - MainKey(isDefaultKey = true) - ) - } -} ``` -## Why would I want to use Enro? -#### Support the navigation requirements of large multi-module Applications, while allowing flexibility to define rich transitions between specific destinations - -A multi-module application has different requirements to a single-module application. Individual modules will define Activities, Fragments or @Composables, and other modules will want to navigate to these Activities/Fragments/@Composables. By detatching the NavigationKeys from the destinations themselves, this allows NavigationKeys to be defined in a common/shared module which all other modules depend on. Any module is then able to navigate to another by using one of the NavigationKeys, without knowing about the Activity or Fragment that it is going to. FeatureOneActivity and FeatureTwoActivity don't know about each other, but they both know that FeatureOneKey and FeatureTwoKey exist. A simple version of this solution can be created in less than 20 lines of code. - -However, truly beautiful navigation requires knowledge of both the originator and the destination. Material design's shared element transitions are an example of this. If FeatureOneActivity and FeatureTwoActivity don't know about each other, how can they collaborate on a shared element transition? Enro allows transitions between two navigation destinations to be overridden for that specific case, meaning that FeatureOneActivity and FeatureTwoActivity might know nothing about each other, but the application that uses them will be able to define a navigation override that adds shared element transitions between the two. - -#### Allow navigation to be triggered at the ViewModel layer of an Application -Enro provides a custom extension function similar to AndroidX's `by viewModels()`, called `by enroViewModels()`, which works in the exact same way. However, when you use `by enroViewModels()` to construct a ViewModel, you are able to use a `by navigationHandle()` statement within your ViewModel. This `NavigationHandle` works in the exact same way as an Activity or Fragment's `NavigationHandle`, and can be used in the exact same way. - -This means that your ViewModel can be put in charge of the flow through your Application, rather than needing to use a `LiveData()` (or similar) in your ViewModel. When we use things like `LiveData()` we are able to test the ViewModel's intent to navigate, but there's still the reliance on the Activity/Fragment implementing the response to the navigation event correctly. In the case of retrieving a result from another screen, this gap grows even wider, and there becomes an invisible contract between the ViewModel and Activity/Fragment: The ViewModel expects that if it sets a particular `NavigationEvent` in the `LiveData`, that the Activity/Fragment will navigate to the correct place, and then once the navigation has been successful and a result has been returned, that the Activity/Fragment will call the correct method on the ViewModel to provide the result. This invisible contract results in extra boilerplate "wiring" code, and a gap for bugs to slip through. Instead, using Enro's ViewModel integration, you allow your ViewModel to be precise and clear about it's intention, and about how to handle a result. - -## Compose Support -Here is an example of a Composable function being used as a NavigationDestination: - +### In a Composable: ```kotlin -@Composable -@NavigationDestination(MyComposeKey::class) -fun MyComposableScreen() { - val navigation = navigationHandle() - - Button( - content = { Text("Hello, ${navigation.key}") }, - onClick = { - navigation.forward(MyListKey(...)) - } - ) -} -``` -#### Nested Composables -Enro's Composable support is based around the idea of an "EnroContainer" Composable, which can be added to a Fragment, Activity or another Composable. The EnroContainer works much like a FrameLayout being used as a container for Fragments. - -Here is an example of creating a Composable that supports nested Composable navigation in Enro: - -```kotlin @Composable -@NavigationDestination(MyComposeKey::class) -fun MyNestedComposableScreen() { - val navigation = navigationHandle() - val navigationContainer = rememberNavigationContainer( - accept = { it is NestedComposeKey }, - emptyBehavior = EmptyBehavior.AllowEmpty - ) - - Column { - EnroContainer( - container = navigationContainer - ) - Button( - content = { Text("Open Nested") }, - onClick = { - navigation.forward(NestedComposeKey()) - } - ) - } +@NavigationDestination(SelectDate::class) +fun SelectDateComposable() { + // providing a type to `navigationHandle()` gives you access to the NavigationKey + // used to open this destination, and you can use this to read the + // arguments for the destination + val navigation = navigationHandle() + // ... + Button(onClick = { + navigation.closeWithResult( /* pass a local date here to return that as a result */ ) + }) { /* ... */ } } -@Composable -@NavigationDestination(NestedComposeKey::class) -fun NestedComposableScreen() = Text("Nested Screen!") ``` -In the example above, we have defined an Enro Container Controller which will accept Navigation Keys of type "NestedComposeKey". When the user clicks on the button "Open Nested", we execute a forward instruction to a NestedComposeKey. Because there is an available container which accepts NestedComposeKey instructions, the Composable for the NestedComposeKey (NestedComposableScreen in the example above) will be placed inside the EnroContainer defined in MyNestedComposableScreen. - -EnroContainerControllers can be configured to have some instructions pre-launched as their initial state, can be configured to accept some/all/no keys, and can be configured with an "EmptyBehavior" which defines what will happen when the container becomes empty due to a close action. The default close behavior is "AllowEmpty", but this can be set to "CloseParent", which will pass the close instruction up to the Container's parent, or "Action", which will allow any custom action to occur when the container becomes empty. - -#### Dialog and BottomSheet support -Composable functions declared as NavigationDestinations can be used as Dialog or ModalBottomSheet type destinations. To do this, make the Composable function an extension function on either `DialogDestination` or `BottomSheetDestination`. This will cause the Composable to be launched as a dialog, escaping the current navigation context of the screen. - -Here's an example: - +### Without annotation processing: +If you'd prefer to avoid annotation processing, you can use a DSL to define these bindings when creating your application (see [here]() for more information): ```kotlin -@Composable -@NavigationDestination(DialogComposableKey::class) -fun DialogDestination.DialogComposableScreen() { - configureDialog { ... } -} -@Composable -@OptIn(ExperimentalMaterialApi::class) -@NavigationDestination(BottomSheetComposableKey::class) -fun BottomSheetDestination.BottomSheetComposableScreen() { - configureBottomSheet { ... } +// this needs to be registered with your application +val exampleNavigationComponent = createNavigationComponent { + fragmentDestination() + composableDestination { SelectDateComposable() } } -``` \ No newline at end of file + +``` + +# Applications using Enro +

+ + + +   +   + + + +