Skip to content

Commit

Permalink
Add new documentation for navigation keys, results and multi-module-p…
Browse files Browse the repository at this point in the history
…rojects.md
  • Loading branch information
isaac-udy committed Feb 4, 2024
1 parent df0e6e0 commit 03eeac2
Show file tree
Hide file tree
Showing 6 changed files with 253 additions and 1 deletion.
8 changes: 8 additions & 0 deletions docs/frequently-asked-questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ When you perform navigation, you ask for a particular NavigationKey to be opened

From within a screen/destination, you have access to the NavigationKey that was used when opening it, and you can use this to read the inputs/parameters/arguments that were used.

See [NavigationKeys](./navigation-keys.md) for more information.

</details>

<details markdown="block">
Expand Down Expand Up @@ -479,4 +481,10 @@ fun ExampleScreen() {
<summary class="faq-summary">
How do I do analytics when a user views a screen?
</summary>

Enro allows you to create `EnroPlugin` classes, and register these with the `navigationController`. These plugins can be used to perform side-effects when a screen is opened or closed, and can be used to perform analytics, logging, or any other side-effect. The `EnroLogger` plugin that is defined within the Enro library is an example of this. The key functions to be interested in are:
* `onOpened(navigationHandle: NavigationHandle)` which is called the first time a screen is opened. This should be invoked once per screen.
* `onActive(navigationHandle: NavigationHandle)` which is called whenever a screen becomes "active", which essentially means whenever that screen would receive the system back button press. This can be invoked multiple times for a screen.
* `onClosed(navigationHandle: NavigationHandle)` which is called whenever a screen is closed. This should be invoked once per screen.

</details>
1 change: 1 addition & 0 deletions docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ nav_order: 1
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](./installing-enro.md)
* [Navigation Keys](./navigation-keys.md)
* [Navigation Destinations](./navigation-destinations.md)
* [Navigation Handles](./navigation-handles.md)
* [Navigation Containers](./navigation-containers.md)
* [Testing](./testing.md)
Expand Down
12 changes: 12 additions & 0 deletions docs/multi-module-projects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Multi-module projects
Enro was designed to support large multi-module projects just as well as it supports small projects. There are a few things to keep in mind when using Enro in a multi-module project, but it is generally very simple to set up.

The most important part of supporting multi-module projects is the fact that NavigationKeys can be defined seperately to the destinations/screens that they are bound to. This means that you can define a NavigationKey in one module, and bind it to a destination in another module.

Exactly how this is done depends on the module structure of the project in question. Here are some examples of different ways that this might work in a multi-module project:
1. A single module contains all the NavigationKeys. Modules that depend on this module can then bind these NavigationKeys to destinations, or use these NavigationKeys for navigation.
2. Each "feature" module defines an "api" or "public" module, which contains all of the NavigationKeys for that feature. Other modules can then depend on this "api" module use these NavigationKeys for navigation. In this situation, it would be expected that there is an "internal", "private" or "implementation" which provides the destinations for these NavigationKeys.

Essentially, to make Enro work across a multi-module project, all you need to do is make sure that the NavigationKeys are defined in a location that is visible to the modules that need to use them, either for binding the destinations or for performing navigation to those NavigationKeys.

For more information on defining NavigationKeys, please see the [NavigationKeys documentation](./navigation-keys.md).
1 change: 1 addition & 0 deletions docs/navigation-destinations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Navigation Containers
104 changes: 103 additions & 1 deletion docs/navigation-keys.md
Original file line number Diff line number Diff line change
@@ -1 +1,103 @@
# Navigation Keys
# NavigationKeys
NavigationKeys are the foundation of Enro. They are the contract that defines how navigation works in your application.

A NavigationKey is a simple data class that represents a screen in your application. It can be thought of like the function signature or interface for a screen. Just like a function signature, a NavigationKey represents a contract.

## Defining NavigationKeys
To define a NavigationKey, create a data class that implements the NavigationKey interface. There are several different interfaces underneath NavigationKey which provide different options. The interfaces that your NavigationKeys implement will depend on the type of navigation that the NavigationKey should support.

Note on Parcelable:
The NavigationKey interface extends Parcelable. All NavigationKeys must be parcelable so that they can be passed between screens, and saved/restored during configuration changes and application process death. Using the `kotlin-parcelize` plugin and the `@Parcelize` annotation to generate a Parcelable implementation for your NavigationKeys is the easiest way to support this.

### SupportsPush
NavigationKeys that extend `NavigationKey.SupportsPush` can be used to push a new screen onto a NavigationContainer's backstack. This is the most common type of navigation, and is used for screens that should fill the entire space within a container. When a screen is pushed, the previous screen is hidden, and the new screen is shown. Only the top-most pushed screen will be visible within a container.

```kotlin

@Parcelize
data class ShowUserProfile(
val userId: UserId
) : NavigationKey.SupportsPush

```

### SupportsPresent
NavigationKeys that extend `NavigationKey.SupportsPresent` can be used to present a new screen on top of the current screen. This is useful for screens that should be shown in a dialog, or screens that should only take up part of the space within a container. When a screen is presented, the top-most pushed screen will be visible underneath it.

```kotlin
data class ShowUpdateRequired(
val updateUrl: String
) : NavigationKey.SupportsPresent
```

### WithResult
NavigationKeys that implement `NavigationKey.SupportsPush.WithResult`, or `NavigationKey.SupportsPresent.WithResult` can be used to declare screens that return a result. Both SupportsPush and SupportsPresent screens can return results.

```kotlin

@Parcelize
data class SelectDate(
val minDate: LocalDate? = null,
val maxDate: LocalDate? = null,
) : NavigationKey.SupportsPresent.WithResult<LocalDate>

```

### Mixing and matching
A NavigationKey can support multiple types of navigation. For example, a screen that can be pushed, presented, and returns a result could look like this:

```kotlin

@Parcelize
data class MixAndMatchScreen(
val parameter: String,
) : NavigationKey.SupportsPush.WithResult<String>, NavigationKey.SupportsPresent.WithResult<String>

```

## Using NavigationKeys to Navigate
To perform navigation using a NavigationKey, you'll need to get a NavigationHandle. A NavigationHandle is a simple interface that allows you to perform navigation. The syntax for getting a NavigationHandle is slightly different depending on the type of screen you're in. For more information on NavigationHandles, please see the [NavigationHandles documentation](./navigation-handles.md).

To push or present, all you need is a NavigationHandle:
```kotlin

val navigationHandle = // get a navigation handle from somewhere

// Push a screen onto the backstack
navigationHandle.push(
ShowUserProfile(userId = "1234")
)

// Present a screen on top of the current screen
navigationHandle.present(
ShowUpdateRequired(updateUrl = "https://example.com/update")
)

```

To receive a result from a destination, you'll need to set up a NavigationResultChannel. Just with a NavgationHandle, the syntax for creating a NavigationResultChannel is slightly different depending on the type of screen you're in. For more information on NavigationResultChannels, please see the [Navigation results documentation](./navigation-results.md).

When you have created a NavigationResultChannel, it is very similar to using a NavigationHandle to perform navigation. You can use the `push` or `present` functions to either push or present the NavigationKey. The only difference is that you will receive a result from the destination, which will be sent to the `onResult` lambda that you provide when creating the NavigationResultChannel.

```kotlin

val resultChannel by registerForNavigationResult<LocalDate> { selectedDate: LocalDate ->
/* do something! */
}

resultChannel.present(
SelectDate(maxDate = LocalDate.now()
)

```

## Binding NavigationKeys to a destination
Once you've defined a NavigationKey, it is important to bind it to a destination. A destination is a screen that will be shown when the NavigationKey is used. Activities, Fragments and Composables can all be used as NavigationDestinations. In general, to bind a NavigationKey to a destination, you'll need to either annotate the destination with `@NavigationDestination` and providing a class reference to the NavigationKey, or manually bind the NavigationKey to the destination when creating the NavigationController for your application. For more information on NavigationDestinations, please see the [Navigation destinations documentation](./navigation-destinations.md).

## Naming NavigationKeys
Enro does not make a strong recommendation on how NavigationKeys should be named at this stage. There are however some conventions that have been identified in different projects. The most important thing is to be consistent within your own project, and use a pattern that feels natural to you. Here are some common patterns that have been identified in different projects:

1. Name NavigationKeys like actions `ShowUserProfile`, `SelectDate`, `ShowUpdateRequired`. This makes it clear that the NavigationKey represents an action, and that invoking the action will result in a screen being shown.
2. Name NavigationKeys using a "screen" suffix `UserProfileScreen`, `DateSelectionScreen`, `UpdateRequiredScreen`. This makes it clear that the NavigationKey represents a screen, and that the screen can be shown by invoking the NavigationKey.
3. Name NavigationKeys using a "key" suffix `UserProfileKey`, `DateSelectionKey`, `UpdateRequiredKey`. This makes it clear that the NavigationKey represents a NavigationKey.
4. Name NavigationKeys using a "destination" suffix `UserProfileDestination`, `DateSelectionDestination`, `UpdateRequiredDestination`. This makes it clear that the NavigationKey represents a NavigationDestination binding.
128 changes: 128 additions & 0 deletions docs/navigation-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Navigation Results
The ability for a NavigationKey to define a result type is an important feature of Enro. It allows you to define a rich contract for a screen, where the contract represents not just an input type, but also an output type. This allows screens within an application to be more independent of one another and helps screens be as re-usable as possible.

Making use of this feature is very simple, and is done by defining a NavigationKey that implements `NavigationKey.SupportsPush.WithResult` or `NavigationKey.SupportsPresent.WithResult`. For more information on defining NavigationKeys, please see the [NavigationKeys documentation](./navigation-keys.md).

## Defining a `NavigationKey.WithResult`
Any NavigationKey can define a result type by implementing the `...WithResult<T>` interface, where `T` is the type of the result. For example, a NavigationKey that returns a `LocalDate` might look like this:

```kotlin

@Parcelize
data class SelectDate(
val minDate: LocalDate? = null,
val maxDate: LocalDate? = null,
) : NavigationKey.SupportsPresent.WithResult<LocalDate>

```

For more information on defining NavigationKeys, please see the [NavigationKeys documentation](./navigation-keys.md).

## Receiving results
Receiving a result from a NavigationKey works in a similar way to Android's `registerForActivityResult` functionality. To receive a result, you'll need to create a `NavigationResultChannel` using the `registerForNavigationResult` function. This function takes a lambda that will be invoked when a result is received. The lambda will be invoked with the result value.

### Defining a `NavigationResultChannel`
A `NavigationResultChannel` can be defined in any Activity, Fragment, Composable or Enro-supported ViewModel. The syntax for defining a `NavigationResultChannel` is slightly different depending on the type of screen you're in, but always uses a function called `registerForNavigationResult`.

`registerForNavigationResult` takes the following arguments:
1. (Required) A generic type argument, which represents the type of the result that will be received.
2. (Required) An `onResult` lambda, which will be invoked when a result is received, which receives a single argument of the generic type provided to `registerForNavigationResult`.
3. (Optional) An `onClosed` lambda, which will be invoked when if a screen opened using the result channel is closed without a result being sent.

When you have created a result channel using `registerForNavigationResult<T>`, the result channel can be used to `push` or `present` any NavigationKey that implements `NavigationKey.SupportsPush.WithResult<T>` or `NavigationKey.SupportsPresent.WithResult<T>`. The result channel will then receive the result from the destination, and invoke the `onResult` lambda that was provided when creating the result channel. The `T` type of the `...WithResult<T>` NavigationKey must match the `T` used when creating the result channel.

If there are multiple result channels in the same screen, registered for the same `T`, they can all be used to receive results, and the result channel that is used to `push` or `present` will be the one that receives the result (this works safely across configuration change and process death).

#### Activities/Fragments/ViewModels
In an Activity, Fragment or ViewModel, the `registerForNavigationResult` function should be used as follows:

#### Composables
In a Composable, the `registerForNavigationResult` function should be used as follows:

```kotlin
@Composable
fun ExampleComposable() {
val exampleResultChannel = registerForNavigationResult<ExampleResult> { result: ExampleResult ->
// handle result
}

Button(
onClick = { exampleResultChannel.present(ExampleResultNavigationKey()) }
) {
Text("Launch Example Result")
}
}
```

```kotlin
class MyActivityFragmentOrViewModel : ... {
private val exampleResultChannel by registerForNavigationResult<ExampleResult> { result: ExampleResult ->
// handle result
}

fun launchExampleResult() {
exampleResultChannel.present(ExampleResultNavigationKey())
}
}

```

## Sending results
From a screen that is bound to a `...WithResult<T>` NavigationKey, you can send a result by calling the `closeWithResult` function on the NavigationHandle. This function takes a single argument, which is the result value. The type of the result value must match the type of the `...WithResult<T>` NavigationKey. The `closeWithResult<T>` function is only available for `TypedNavigationHandle<K>`, where `K` is a NavigationKey `...WithResult<T>`, which have been created through the `navigationHandle<K>()` function. For more information on NavigationHandles, please see the [NavigationHandles documentation](./navigation-handles.md).

Essentially, when you want to send a result, make sure you use the typed version of the `navigationHandle` functions, and pass in the type of the NavigationKey `...WithResult<T>` that you want to send a result for, and then the `closeWithResult<T>` will be available.

It is also possible to delegate a result to another screen, rather than return the result yourself. For more information on delegating results, please see the [Delegating results](#delegating-results) section.

#### Composables
In a Composable, the `closeWithResult` function should be used as follows:

```kotlin
@Parcelize
class ExampleResultKey: NavigationKey.SupportsPush.WithResult<ExampleResult>

@Composable
fun ExampleComposable() {
val navigationHandle = navigationHandle<ExampleResultKey>()
// private val navigationHandle = navigationHandle() <- this won't work, as it returns an untyped NavigationHandle

Button(
onClick = { navigationHandle.closeWithResult(ExampleResult(...)) }
) {
Text("Close with result")
}
}
```

#### Activities/Fragments/ViewModels
In an Activity, Fragment or ViewModel, the `closeWithResult` function should be used as follows:

```kotlin
@Parcelize
class ExampleResultKey: NavigationKey.SupportsPush.WithResult<ExampleResult>

class ExampleFragmentActivityOrViewModel : ... {
private val navigationHandle by navigationHandle<ExampleResultKey>()
// private val navigationHandle by navigationHandle() <- this won't work, as it returns an untyped NavigationHandle

fun sendResult() {
navigationHandle.closeWithResult(ExampleResult(...))
}
}
```

## Advanced
### Delegating results
Enro provides support for delegating results to another screen. This functionality can be used to build small, multi-step flows that are made up of multiple screens. For example, a flow that requires the user to select a date, and then select a time, could be built using two screens, where the first screen delegates to the second screen. These kinds of flows are often referred to as "embedded navigation flows", as the logic for the flow is embedded within each screen within the flow. When the screen which is delegated to returns a result, the screen that delegated to it will be closed at the same time, and the result will be delivered to the original screen that requested the result.

For example:
1. Starting on "Screen A" where the backstack is `... -> A`
2. Screen A requests a result from Screen B, the backstack becomes `... -> A -> B`
3. Screen B delegates its result to Screen C, the backstack becomes `... -> A -> B -> C`
4. There are several possible outcomes:
a. Screen C returns a result, which would cause the result to be delivered to Screen A, and the backstack would become `... -> A`
b. Screen C closes without a result, which would cause no result to be delivered, but Screen C to close, and the backstack would become `... A -> B`
c. Screen C continues the by delegating to Screen D, the backstack would become `... -> A -> B -> C -> D`, and the result behaviour would be the same when Screen D is closed; the result would be delivered to Screen A, and the backstack would become `... -> A`


To delegate a result, you'll need to use the `deliverResultFromPush` or `deliverResultFromPresent` functions on the `NavigationHandle`. This function takes a single argument, which is the `NavigationKey` that you want to delegate to. The `NavigationKey` that you delegate to must be a `...WithResult<T>` NavigationKey, where `T` is the same type as the `...WithResult<T>` NavigationKey that you are delegating from.

0 comments on commit 03eeac2

Please sign in to comment.