Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Navigation sample with components #64

Merged
merged 2 commits into from
Mar 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Fabulous.MauiControls.sln
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "BasicNavigation", "samples\
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "NavigationPath", "samples\Navigation\NavigationPath\NavigationPath.fsproj", "{5B3F6C4E-82CF-442F-BFB4-216C1CD85700}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ComponentNavigation", "samples\Navigation\ComponentNavigation\ComponentNavigation.fsproj", "{66532A61-1BB8-4BD1-A281-A160ABB0EFE7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -128,6 +130,10 @@ Global
{5B3F6C4E-82CF-442F-BFB4-216C1CD85700}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B3F6C4E-82CF-442F-BFB4-216C1CD85700}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5B3F6C4E-82CF-442F-BFB4-216C1CD85700}.Release|Any CPU.Build.0 = Release|Any CPU
{66532A61-1BB8-4BD1-A281-A160ABB0EFE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{66532A61-1BB8-4BD1-A281-A160ABB0EFE7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{66532A61-1BB8-4BD1-A281-A160ABB0EFE7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{66532A61-1BB8-4BD1-A281-A160ABB0EFE7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{67FB01A1-1A3E-4A3B-83DC-7D63B56FB1A1} = {35A6823C-8312-4F92-818A-5117BB31A569}
Expand All @@ -146,5 +152,6 @@ Global
{3A3581BD-4228-49B0-84D5-AF39D620BA34} = {87C8E9E8-497E-46DB-90FE-4402E0CB230A}
{CE61493B-86CC-49CE-9443-F25F1ECB15C9} = {3A3581BD-4228-49B0-84D5-AF39D620BA34}
{5B3F6C4E-82CF-442F-BFB4-216C1CD85700} = {3A3581BD-4228-49B0-84D5-AF39D620BA34}
{66532A61-1BB8-4BD1-A281-A160ABB0EFE7} = {3A3581BD-4228-49B0-84D5-AF39D620BA34}
EndGlobalSection
EndGlobal
17 changes: 17 additions & 0 deletions samples/Navigation/ComponentNavigation/AppMsg.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace ComponentNavigation

/// With each component runs in an isolated context, so we need a way to communicate between them.
/// We define application-wide messages here, and a dispatcher to send and receive messages.
[<RequireQualifiedAccess>]
type AppMsg = | BackButtonPressed

type IAppMessageDispatcher =
abstract Dispatched: IEvent<AppMsg>
abstract member Dispatch: AppMsg -> unit

type AppMessageDispatcher() =
let dispatched = Event<AppMsg>()

interface IAppMessageDispatcher with
member _.Dispatched = dispatched.Publish
member _.Dispatch(msg) = dispatched.Trigger(msg)
107 changes: 107 additions & 0 deletions samples/Navigation/ComponentNavigation/ComponentNavigation.fsproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net8.0-android;net8.0-ios;net8.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net8.0-windows10.0.19041.0</TargetFrameworks>
<!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET -->
<!-- <TargetFrameworks>$(TargetFrameworks);net7.0-tizen</TargetFrameworks> -->
<OutputType>Exe</OutputType>
<RootNamespace>ComponentNavigation</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<EnableDefaultItems>false</EnableDefaultItems>

<!-- Display name -->
<ApplicationTitle>ComponentNavigation</ApplicationTitle>

<!-- App Identifier -->
<ApplicationId>org.fabulous.maui.componentnavigation</ApplicationId>
<ApplicationIdGuid>9abd223e-09e7-4649-b22b-7395cb4724e1</ApplicationIdGuid>

<!-- Versions -->
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>

<TargetPlatformIdentifier Condition=" $(TargetPlatformIdentifier) == '' ">$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)'))</TargetPlatformIdentifier>

<SupportedOSPlatformVersion Condition="$(TargetPlatformIdentifier) == 'ios'">14.2</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$(TargetPlatformIdentifier) == 'maccatalyst'">14.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$(TargetPlatformIdentifier) == 'android'">21.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$(TargetPlatformIdentifier) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$(TargetPlatformIdentifier) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
<SupportedOSPlatformVersion Condition="$(TargetPlatformIdentifier) == 'tizen'">6.5</SupportedOSPlatformVersion>
</PropertyGroup>

<ItemGroup>
<Compile Include="AppMsg.fs" />
<Compile Include="Navigation.fs" />
<Compile Include="PageA.fs" />
<Compile Include="PageB.fs" />
<Compile Include="PageC.fs" />
<Compile Include="Sample.fs" />
<Compile Include="MauiProgram.fs" />
</ItemGroup>

<ItemGroup>
<!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />

<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />

<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.svg" BaseSize="168,208" />

<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
<MauiAsset Include="Resources\Raw\*" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />

<!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
</ItemGroup>

<ItemGroup Condition="$(TargetPlatformIdentifier) == 'android'">
<AndroidResource Include="$(AndroidProjectFolder)Resources/*/*" />
<AndroidResource Remove="$(AndroidProjectFolder)Resources/raw/.*" />
<AndroidResource Update="$(AndroidProjectFolder)Resources/raw/*" />
<AndroidAsset Include="$(AndroidProjectFolder)Assets/**/*" Exclude="$(AndroidProjectFolder)Assets/**/.*/**" />
<AndroidManifest Include="$(AndroidProjectFolder)AndroidManifest.xml" />
<Compile Include="$(AndroidProjectFolder)MainActivity.fs" />
<Compile Include="$(AndroidProjectFolder)MainApplication.fs" />
</ItemGroup>

<ItemGroup Condition="$(TargetPlatformIdentifier) == 'ios'">
<None Include="$(iOSProjectFolder)Info.plist" LogicalName="Info.plist" />
<Compile Include="$(iOSProjectFolder)AppDelegate.fs" />
<Compile Include="$(iOSProjectFolder)Program.fs" />
</ItemGroup>

<ItemGroup Condition="$(TargetPlatformIdentifier) == 'maccatalyst'">
<None Include="$(MacCatalystProjectFolder)Info.plist" LogicalName="Info.plist" />
<Compile Include="$(MacCatalystProjectFolder)AppDelegate.fs" />
<Compile Include="$(MacCatalystProjectFolder)Program.fs" />
</ItemGroup>

<ItemGroup Condition="$(TargetPlatformIdentifier) == 'windows'">
<Manifest Include="$(WindowsProjectFolder)app.manifest" />
<AppxManifest Include="$(WindowsProjectFolder)Package.appxmanifest" />
<Compile Include="$(WindowsProjectFolder)App.fs" />
<Compile Include="$(WindowsProjectFolder)Main.fs" />

<PackageReference Include="FSharp.Maui.WinUICompat" />
</ItemGroup>

<ItemGroup Condition="$(TargetPlatformIdentifier) == 'tizen'">
<TizenManifestFile Include="$(TizenProjectFolder)tizen-manifest.xml" />
<Compile Include="$(TizenProjectFolder)Main.fs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="FSharp.Core" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\src\Fabulous.MauiControls\Fabulous.MauiControls.fsproj" />
</ItemGroup>

</Project>
19 changes: 19 additions & 0 deletions samples/Navigation/ComponentNavigation/MauiProgram.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace ComponentNavigation

open Microsoft.Maui.Hosting
open Fabulous.Maui

type MauiProgram =
static member CreateMauiApp() =
let nav = NavigationController()
let appMsgDispatcher = AppMessageDispatcher()

MauiApp
.CreateBuilder()
.UseFabulousApp(Sample.view nav appMsgDispatcher)
.ConfigureFonts(fun fonts ->
fonts
.AddFont("OpenSans-Regular.ttf", "OpenSansRegular")
.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold")
|> ignore)
.Build()
66 changes: 66 additions & 0 deletions samples/Navigation/ComponentNavigation/Navigation.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
namespace ComponentNavigation

open Fabulous

/// This is the centerpiece of navigating through paths:
/// A single enum regrouping all the navigation routes with their arguments
[<RequireQualifiedAccess>]
type NavigationRoute =
| PageA
| PageB of initialCount: int
| PageC of someArgs: string * stepCount: int

/// The NavigationController is used to notify the intention to navigate to a new page (or go back).
/// We listen to it in a Cmd that will dispatch a message to the root of the application to trigger the actual navigation.
type NavigationController() =
let navigationRequested = Event<NavigationRoute>()
let backNavigationRequested = Event<unit>()

member this.NavigationRequested = navigationRequested.Publish
member this.BackNavigationRequested = backNavigationRequested.Publish

member this.NavigateTo(path: NavigationRoute) = navigationRequested.Trigger(path)

member this.NavigateBack() = backNavigationRequested.Trigger()

/// The Navigation module is a set of helper functions that will wrap the call to NavigationController into a Cmd.
/// We do that because navigation is a side-effect and we want to keep it in a Cmd.
module Navigation =
let private navigateTo (nav: NavigationController) path : Cmd<'msg> = [ fun _ -> nav.NavigateTo(path) ]

let navigateBack (nav: NavigationController) : Cmd<'msg> = [ fun _ -> nav.NavigateBack() ]

let navigateToPageA nav = navigateTo nav NavigationRoute.PageA

let navigateToPageB nav initialCount =
navigateTo nav (NavigationRoute.PageB initialCount)

let navigateToPageC nav someArgs stepCount =
navigateTo nav (NavigationRoute.PageC(someArgs, stepCount))

/// The NavigationStack represents the history of the navigation.
/// This is a simple stack of pages that the app will use to remember and display the pages needed.
type NavigationStack =
{ BackStack: NavigationRoute list
CurrentPage: NavigationRoute
ForwardStack: NavigationRoute list }

static member Init(path: NavigationRoute) =
{ BackStack = []
CurrentPage = path
ForwardStack = [] }

member this.Push(path: NavigationRoute) =
{ BackStack = this.CurrentPage :: this.BackStack
CurrentPage = path
ForwardStack = [] }

member this.Pop() =
match this.BackStack with
| [] -> this
| head :: tail ->
{ BackStack = tail
CurrentPage = head
ForwardStack = [] }

member this.UpdateCurrentPage(path: NavigationRoute) = { this with CurrentPage = path }
75 changes: 75 additions & 0 deletions samples/Navigation/ComponentNavigation/PageA.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
namespace ComponentNavigation

open Fabulous
open Fabulous.Maui

open type Fabulous.Maui.View

/// Each page is "isolated". They have their own MVU loop and own types.
/// The only dependency they receive from outside is the NavigationController, which is passed to the update function.
module PageA =
type Model = { IsActive: bool; Count: int }

type Msg =
| Active
| Inactive
| Increment
| Decrement
| GoBack
| GoToPageB
| GoToPageC
| BackButtonPressed

/// Since the NavigationRoute.PageA doesn't take arguments, the init function excepts a unit parameter.
let init () =
{ IsActive = false; Count = 0 }, Cmd.none

let update (nav: NavigationController) msg model =
match msg with
| Active -> { model with IsActive = true }, Cmd.none
| Inactive -> { model with IsActive = false }, Cmd.none
| Increment -> { model with Count = model.Count + 1 }, Cmd.none
| Decrement -> { model with Count = model.Count - 1 }, Cmd.none
| GoBack -> model, Navigation.navigateBack nav
| GoToPageB -> model, Navigation.navigateToPageB nav model.Count
| GoToPageC -> model, Navigation.navigateToPageC nav "Hello from Page A!" model.Count
| BackButtonPressed -> { model with Count = model.Count - 1 }, Cmd.none

let subscribe (appMsgDispatcher: IAppMessageDispatcher) model =
let localAppMsgSub dispatch =
appMsgDispatcher.Dispatched.Subscribe(fun msg ->
match msg with
| AppMsg.BackButtonPressed -> dispatch BackButtonPressed)

[ if model.IsActive then
[ nameof localAppMsgSub ], localAppMsgSub ]

let program nav appMsgDispatcher =
Program.statefulWithCmd init (update nav)
|> Program.withSubscription(subscribe appMsgDispatcher)

let view nav appMsgDispatcher =
Component(program nav appMsgDispatcher) {
let! model = Mvu.State

ContentPage(
Grid(coldefs = [ Star ], rowdefs = [ Star; Auto ]) {
VStack() {
Label($"Count: {model.Count}").centerTextHorizontal()

Button("Increment", Increment)
Button("Decrement", Decrement)
}

(VStack() {
Button("Go back", GoBack)
Button("Go to Page B", GoToPageB)
Button("Go to Page C", GoToPageC)
})
.gridRow(1)
}
)
.title("Page A")
.onNavigatedTo(Active)
.onNavigatedFrom(Inactive)
}
79 changes: 79 additions & 0 deletions samples/Navigation/ComponentNavigation/PageB.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
namespace ComponentNavigation

open Fabulous
open Fabulous.Maui

open type Fabulous.Maui.View

module PageB =
type Model =
{ IsActive: bool
InitialCount: int
Count: int }

type Msg =
| Active
| Inactive
| Increment
| Decrement
| GoBack
| GoToPageA
| GoToPageC

/// Contrary to PageA, NavigationPath.PageB has a initialCount argument so the init function will receive it.
let init initialCount =
{ IsActive = false
InitialCount = initialCount
Count = initialCount },
Cmd.none

let update (nav: NavigationController) msg model =
match msg with
| Active -> { model with IsActive = true }, Cmd.none
| Inactive -> { model with IsActive = false }, Cmd.none
| Increment -> { model with Count = model.Count + 1 }, Cmd.none
| Decrement -> { model with Count = model.Count - 1 }, Cmd.none
| GoBack -> model, Navigation.navigateBack nav
| GoToPageA -> model, Navigation.navigateToPageA nav
| GoToPageC -> model, Navigation.navigateToPageC nav "Hello from Page A!" model.Count

let subscribe (appMsgDispatcher: IAppMessageDispatcher) model =
let localAppMsgSub dispatch =
appMsgDispatcher.Dispatched.Subscribe(fun msg ->
match msg with
| AppMsg.BackButtonPressed -> dispatch GoBack)

[ if model.IsActive then
[ nameof localAppMsgSub ], localAppMsgSub ]

let program nav appMsgDispatcher =
Program.statefulWithCmd init (update nav)
|> Program.withSubscription(subscribe appMsgDispatcher)

let view nav appMsgDispatcher arg =
Component(program nav appMsgDispatcher, arg) {
let! model = Mvu.State

ContentPage(
Grid(coldefs = [ Star ], rowdefs = [ Star; Auto ]) {
VStack() {
Label($"Initial count: {model.InitialCount}")

Label($"Count: {model.Count}").centerTextHorizontal()

Button("Increment", Increment)
Button("Decrement", Decrement)
}

(VStack() {
Button("Go back", GoBack)
Button("Go to Page A", GoToPageA)
Button("Go to Page C", GoToPageC)
})
.gridRow(1)
}
)
.title("Page B")
.onNavigatedTo(Active)
.onNavigatedFrom(Inactive)
}
Loading
Loading