Skip to content

Latest commit

 

History

History
257 lines (197 loc) · 10.9 KB

File metadata and controls

257 lines (197 loc) · 10.9 KB

Hosting & Dependency Injection

What it is. A composition root — SwiftProgram.CreateSwiftApp() — where an app registers its services, logging, and root view, plus the machinery that delivers those services to views. It mirrors .NET MAUI's MauiProgram.cs deliberately: SwiftDotNetApp.CreateBuilder(), builder.Services, builder.Logging, UseX() extensions, and per-platform heads that do nothing but call it.

The SwiftUI analog is @EnvironmentObject / @Environment, but the model here is standard .NET DI (Microsoft.Extensions.DependencyInjection), so it behaves the way .NET developers already expect.


The composition root

One shared file, used by every backend:

// sample/SharedUI/SwiftProgram.cs
public static class SwiftProgram
{
    public static SwiftDotNetApp CreateSwiftApp(Action<SwiftDotNetAppBuilder>? platform = null)
    {
        var builder = SwiftDotNetApp.CreateBuilder();

        builder.UseSwiftApp(_ => new SampleRootView());        // the root view

        builder.Services.AddSingleton<IGreetingService, GreetingService>();
        builder.Services.AddSingleton<IViewLifecycle, ConsoleViewLifecycle>();
        builder.Logging.AddDebug();

        platform?.Invoke(builder);                             // platform-only opt-ins
        return builder.Build();
    }
}

Each platform head is then one line:

[Register("AppDelegate")]
public sealed class AppDelegate : SwiftDotNetAppDelegate
{
    protected override SwiftDotNetApp CreateSwiftApp() => SwiftProgram.CreateSwiftApp(b => b.UseAppleMaps());
}

builder.Configuration is not exposed. ConfigurationManager's binding is reflection-based and is the usual trim casualty under iOS AOT; it can be added when there is a concrete need.

Registering the root view

Form Use when
builder.UseSwiftApp(sp => new ContentView()) Preferred. No runtime constructor discovery, so it is trim/AOT-clean.
builder.UseSwiftApp<ContentView>() Convenient; resolves through ActivatorUtilities, which can surface trim warnings.

The root is retained for the app's lifetime, so CreateRoot() builds it once and returns the same instance on repeat calls.

Getting services into a view

Three delivery styles, in order of preference:

1. [Inject] partial properties — the documented default

public sealed partial class WeatherView : View          // the type must be partial
{
    [Inject] public partial IWeatherService Weather { get; }   // required
    [Inject] public partial IImageCache? Cache { get; }        // nullable ⇒ optional
}

A source generator emits the implementation — plain static assignments, no reflection, which is what keeps this trim/AOT-clean:

// <auto-generated/>
partial class WeatherView : global::SwiftDotNet.IInjectable
{
    private global::IWeatherService? __inject_Weather;
    public partial global::IWeatherService Weather => __inject_Weather ?? throw new ...;

    void global::SwiftDotNet.IInjectable.Inject(global::System.IServiceProvider provider)
    {
        __inject_Weather = global::SwiftDotNet.SwiftHost.Require<global::IWeatherService>(provider);
        __inject_Cache   = global::SwiftDotNet.SwiftHost.Optional<global::IImageCache>(provider);
    }
}

The legacy form [Inject] public IFoo Foo { get; set; } = default!; still works and is assigned directly, but it has no uninjected-read diagnostic — it just hands back null — so prefer the partial form.

2. Constructor injection

Works for anything the container builds, i.e. the root:

builder.UseSwiftApp(sp => new WeatherView(sp.GetRequiredService<IWeatherService>()));

3. Service<T>() — for inline children

Views built inline inside a Body are rebuilt on every render pass and never pass through the container, so they can take neither constructor services nor [Inject]. They use the locator:

public sealed class AuditButton : View
{
    public override View Body =>
        new Button("Refresh", () => { Service<IAudit>().Log("refresh"); });
}

Lifecycle

Two surfaces, raised by the same dispatch.

A view's own hooks — override what you need:

public sealed partial class DetailView : View
{
    protected override void OnCreated() { }        // constructed + [Inject] filled, before first render
    protected override void OnAppearing() { }      // became visible; can fire more than once
    protected override void OnDisappearing() { }   // no longer visible
    protected override void OnDestroyed() { }      // torn down for good
}

IViewLifecycle — a cross-cutting observer, registered in the container. Every registered implementation is called for every retained view:

public interface IViewLifecycle
{
    void OnCreated(View view);
    void OnAppearing(View view);
    void OnDisappearing(View view);
    void OnDestroyed(View view);
}

builder.Services.AddSingleton<IViewLifecycle, AnalyticsViewLifecycle>();

Ordering. Setup (OnCreated, OnAppearing) runs observers → view; teardown (OnDisappearing, OnDestroyed) runs view → observers. Dependency injection is itself just a registered observer, which is what guarantees [Inject] members are filled before your own OnCreated runs.

Initializers

ISwiftInitializer is the analog of MAUI's IMauiInitializeService / IMauiInitializeScopedService, collapsed into one interface:

public interface ISwiftInitializer
{
    void Initialize(IServiceProvider services, bool scoped);
}

Called with scoped: false once during Build() against the app provider, and with scoped: true each time a ViewScope is created — before anything else resolves from that scope. Invocation order is registration order.

Scoped services

ViewScope pairs a retained view with its own IServiceScope, so scoped registrations resolve per view and are disposed with it:

using var page = ViewScope.Create(app.Services, sp => new DetailView());
page.Appearing();
// … page.View is rendered; Service<T>() inside page.Use(...) resolves from this scope

SwiftHost.EnterScope(...) sets the ambient provider so Service<T>() and [Inject] resolve from the scope; outside it, resolution falls back to the app provider.

Status: ViewScope is built and tested but has no production caller yet — the root is deliberately not scoped (it lives as long as the app, so a scope buys nothing), and per-page scopes arrive with the navigation service. See plans/navigation-service-plan.md (paused).

Per-backend behavior

Backend Composition root Notes
iOS / tvOS / macOS SwiftDotNetAppDelegate.CreateSwiftApp() Provider flows into SwiftDotNetHost.CreateRootController(root, services).
Android SwiftDotNetActivity.CreateSwiftApp() Via CreateRootView(context, root, services).
Windows SwiftDotNetApplication.CreateSwiftApp() Via CreateRootElement(root, services).
Linux / GTK Program.Main SwiftDotNetHost.Run(app.CreateRoot(), services: app.Services).
Skia Program.Main SwiftApp.Run(app.CreateRoot(), bridge, app.Services).
Web / Blazor Blazor's own container Reuses Blazor's provider — SwiftProgram.AddSharedServices(builder.Services), then SwiftHost.Services = host.Services. One registration list serves both.

UseX() — opt-in libraries

Optional SwiftDotNet libraries extend the builder, mirroring UseMauiCommunityToolkit():

Extension Package Effect
UseAppleMaps() SwiftDotNet.Maps.Apple Native MapKit renderer for Map.
UseAppleCamera() SwiftDotNet.Controls.Camera.Apple Native AVFoundation renderer for CameraView.
UseMapLibreMaps() SwiftDotNet.Maps.Web MapLibre GL renderer for Map on Web.

These perform process-wide native/renderer registration rather than container registration — the builder is simply the one place an app declares what it uses.

Gotchas

  • [Inject] only works on views the container creates — the root today, pushed pages once navigation lands. Inline Body children are rebuilt every render and are never injected; the generator reports SDN1003 when it can tell. That check is a heuristic (it looks for the view as a generic type argument or constructed in a registration lambda), so a view registered from another assembly can false-positive.
  • Views with [Inject] must be partial, and the partial-property form needs C# 13+.
  • In-repo ProjectReference consumers must reference the generator explicitly — analyzers don't flow transitively. Package consumers get it automatically:
    <ProjectReference Include="..\..\src\SwiftDotNet.SourceGenerators\SwiftDotNet.SourceGenerators.csproj"
                      OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
  • Blazor also has an [Inject] attribute. Any file importing both SwiftDotNet and Microsoft.AspNetCore.Components must qualify which one it means.
  • SwiftHost.ActiveScope is a plain static, valid across synchronous UI-thread work. An async void handler that awaits resumes after the scope has exited — capture services before awaiting.
  • The core library now depends on Microsoft.Extensions.DependencyInjection (and .Logging). The render path itself stays free of them — SwiftHost binds only to BCL IServiceProvider.

Diagnostics

ID Severity Meaning
SDN1001 Error [Inject] on a non-partial type.
SDN1002 Error [Inject] on a property that can't be assigned (non-partial get-only, or init-only).
SDN1003 Warning [Inject] members will never be filled — the view is never container-created.

Status

Piece Status
Builder, SwiftHost, [Inject] generator, lifecycle, initializers ✅ Verified — 107 tests, and the Skia headless harness boots the sample through the container on macOS
Platform heads (Apple, Android, GTK, Skia, Web) ✅ Build verified on macOS; Apple/Android/GTK/Skia/Web compile, Skia + sample run
Windows head 🧩 Updated but unverified — needs Windows to build
ViewScope / scoped per-page services 🧩 Built and tested, no production caller until navigation lands
Navigation service (INavigator) ⏸ Paused — plans/navigation-service-plan.md

See also