Skip to content
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
209 changes: 209 additions & 0 deletions src/ComposeNet.Compose/Compose.cs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,215 @@ static T RememberSaveableWrapper<T>(System.Func<T> factory, object?[]? keys, ICo
}
}

/// <summary>
/// Compose's <c>SideEffect { … }</c>: runs <paramref name="effect"/>
/// on every successful recomposition, <b>after</b> the composition
/// has been applied. Use it to publish managed-side state into
/// objects that aren't managed by Compose (e.g. logging, analytics,
/// pushing a value into a non-Compose Android API).
/// </summary>
/// <remarks>
/// <para>
/// <paramref name="effect"/> must <b>not</b> mutate any state that
/// the same composition reads — that would invalidate the
/// composition Compose just applied and trigger an infinite
/// recomposition loop.
/// </para>
/// <para>
/// Stale captures: closures inside <paramref name="effect"/> see
/// whatever the C# closure captured during the most recent render
/// — <c>SideEffect</c> bodies are recreated every render so this
/// is generally what you want.
/// </para>
/// </remarks>
public static void SideEffect(System.Action effect)
{
System.ArgumentNullException.ThrowIfNull(effect);
var composer = ComposeContext.Current
?? throw new System.InvalidOperationException(
"Compose.SideEffect must be called inside a composition (e.g. inside a SetContent body or a ComposableNode.Render override).");

EffectsKt.SideEffect(new ComposableLambda0(effect), composer, _changed: 0);
}

/// <summary>
/// Compose's <c>DisposableEffect(key1) { … onDispose { … } }</c>:
/// runs <paramref name="effect"/> the first time this call site
/// is composed (and again whenever <paramref name="key1"/> changes),
/// and calls the returned cleanup <see cref="System.Action"/> on
/// key change or when the call site leaves the composition.
/// </summary>
/// <param name="key1">
/// Compose compares this against the previous value using
/// <c>Object.equals</c> via the boxed Java value. Supports
/// <c>null</c>, any <see cref="Java.Lang.Object"/> peer,
/// <see cref="string"/>, and every common .NET primitive.
/// </param>
/// <param name="effect">
/// Setup callback. Must return a non-null cleanup
/// <see cref="System.Action"/> — use <c>() =&gt; { }</c> when
/// there's nothing to clean up.
/// </param>
/// <remarks>
/// Stale captures: closures inside <paramref name="effect"/> see
/// whatever the C# closure captured when the effect was last
/// set up. To observe newer values without invalidating the
/// effect, hoist them into <see cref="MutableState{T}"/>.
/// </remarks>
public static void DisposableEffect(
object? key1,
System.Func<AndroidX.Compose.Runtime.DisposableEffectScope, System.Action> effect)
{
System.ArgumentNullException.ThrowIfNull(effect);
var composer = ComposeContext.Current
?? throw new System.InvalidOperationException(
"Compose.DisposableEffect must be called inside a composition.");

EffectsKt.DisposableEffect(
ComposeBridges.BoxKey(key1),
new DisposableEffectBody(effect),
composer,
_changed: 0);
}

/// <summary>
/// Two-key overload of
/// <see cref="DisposableEffect(object?, System.Func{AndroidX.Compose.Runtime.DisposableEffectScope, System.Action})"/>.
/// </summary>
public static void DisposableEffect(
object? key1,
object? key2,
System.Func<AndroidX.Compose.Runtime.DisposableEffectScope, System.Action> effect)
{
System.ArgumentNullException.ThrowIfNull(effect);
var composer = ComposeContext.Current
?? throw new System.InvalidOperationException(
"Compose.DisposableEffect must be called inside a composition.");

EffectsKt.DisposableEffect(
ComposeBridges.BoxKey(key1),
ComposeBridges.BoxKey(key2),
new DisposableEffectBody(effect),
composer,
_changed: 0);
}

/// <summary>
/// Three-key overload of
/// <see cref="DisposableEffect(object?, System.Func{AndroidX.Compose.Runtime.DisposableEffectScope, System.Action})"/>.
/// </summary>
public static void DisposableEffect(
object? key1,
object? key2,
object? key3,
System.Func<AndroidX.Compose.Runtime.DisposableEffectScope, System.Action> effect)
{
System.ArgumentNullException.ThrowIfNull(effect);
var composer = ComposeContext.Current
?? throw new System.InvalidOperationException(
"Compose.DisposableEffect must be called inside a composition.");

EffectsKt.DisposableEffect(
ComposeBridges.BoxKey(key1),
ComposeBridges.BoxKey(key2),
ComposeBridges.BoxKey(key3),
new DisposableEffectBody(effect),
composer,
_changed: 0);
}

/// <summary>
/// Compose's <c>LaunchedEffect(key1) { … }</c>: launches the C#
/// <paramref name="body"/> as a coroutine the first time this call
/// site is composed (and again whenever <paramref name="key1"/>
/// changes). The previous launch is cancelled on key change or
/// when the call site leaves the composition — the
/// <see cref="System.Threading.CancellationToken"/> passed to
/// <paramref name="body"/> is signalled, and the body should
/// observe it (e.g. via <see cref="System.Threading.Tasks.Task.Delay(int, System.Threading.CancellationToken)"/>).
/// </summary>
/// <param name="key1">
/// Compose compares this against the previous value using
/// <c>Object.equals</c> via the boxed Java value. Pass a stable
/// "version" (e.g. <see cref="System.Guid.Empty"/> stringified, or
/// the literal <c>"once"</c>) when you want the body to run
/// exactly once per call-site lifetime.
/// </param>
/// <param name="body">
/// The async work to run. Honours the supplied
/// <see cref="System.Threading.CancellationToken"/> when the
/// underlying Kotlin <c>Job</c> is cancelled.
/// </param>
/// <remarks>
/// <para>
/// Stale captures: closures inside <paramref name="body"/> see
/// whatever the C# closure captured when the launch started. To
/// observe newer values without restarting the body, read from
/// <see cref="MutableState{T}"/>.
/// </para>
/// </remarks>
public static void LaunchedEffect(
object? key1,
System.Func<System.Threading.CancellationToken, System.Threading.Tasks.Task> body)
{
System.ArgumentNullException.ThrowIfNull(body);
var composer = ComposeContext.Current
?? throw new System.InvalidOperationException(
"Compose.LaunchedEffect must be called inside a composition.");

EffectsKt.LaunchedEffect(
ComposeBridges.BoxKey(key1),
new LaunchedEffectBody(body),
composer,
_changed: 0);
}

/// <summary>
/// Two-key overload of
/// <see cref="LaunchedEffect(object?, System.Func{System.Threading.CancellationToken, System.Threading.Tasks.Task})"/>.
/// </summary>
public static void LaunchedEffect(
object? key1,
object? key2,
System.Func<System.Threading.CancellationToken, System.Threading.Tasks.Task> body)
{
System.ArgumentNullException.ThrowIfNull(body);
var composer = ComposeContext.Current
?? throw new System.InvalidOperationException(
"Compose.LaunchedEffect must be called inside a composition.");

EffectsKt.LaunchedEffect(
ComposeBridges.BoxKey(key1),
ComposeBridges.BoxKey(key2),
new LaunchedEffectBody(body),
composer,
_changed: 0);
}

/// <summary>
/// Three-key overload of
/// <see cref="LaunchedEffect(object?, System.Func{System.Threading.CancellationToken, System.Threading.Tasks.Task})"/>.
/// </summary>
public static void LaunchedEffect(
object? key1,
object? key2,
object? key3,
System.Func<System.Threading.CancellationToken, System.Threading.Tasks.Task> body)
{
System.ArgumentNullException.ThrowIfNull(body);
var composer = ComposeContext.Current
?? throw new System.InvalidOperationException(
"Compose.LaunchedEffect must be called inside a composition.");

EffectsKt.LaunchedEffect(
ComposeBridges.BoxKey(key1),
ComposeBridges.BoxKey(key2),
ComposeBridges.BoxKey(key3),
new LaunchedEffectBody(body),
composer,
_changed: 0);
}

/// <summary>
/// Compose's <c>derivedStateOf { calculation() }</c>: returns a
/// read-only <see cref="DerivedState{T}"/> whose value is lazily
Expand Down
67 changes: 67 additions & 0 deletions src/ComposeNet.Compose/DisposableEffect.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using AndroidX.Compose.Runtime;

namespace ComposeNet;

/// <summary>
/// Tree-syntax wrapper around
/// <see cref="Compose.DisposableEffect(object?, System.Func{DisposableEffectScope, System.Action})"/>.
/// Re-runs <c>Effect</c> on first composition and any time
/// <c>Key1</c> / <c>Key2</c> / <c>Key3</c> changes; calls the cleanup
/// <see cref="System.Action"/> on key change or when this node leaves
/// the composition.
/// </summary>
/// <remarks>
/// <code>
/// new DisposableEffect(_sensorId, scope =&gt;
/// {
/// var registration = SensorRegistry.Subscribe(_sensorId, OnSensorTick);
/// return () =&gt; registration.Dispose();
/// })
/// </code>
/// </remarks>
public sealed class DisposableEffect : ComposableNode
{
readonly object? _key1, _key2, _key3;
readonly int _keyCount;
readonly System.Func<DisposableEffectScope, System.Action> _effect;

/// <summary>Single-key form.</summary>
public DisposableEffect(
object? key1,
System.Func<DisposableEffectScope, System.Action> effect)
{
System.ArgumentNullException.ThrowIfNull(effect);
_key1 = key1; _keyCount = 1; _effect = effect;
}

/// <summary>Two-key form.</summary>
public DisposableEffect(
object? key1,
object? key2,
System.Func<DisposableEffectScope, System.Action> effect)
{
System.ArgumentNullException.ThrowIfNull(effect);
_key1 = key1; _key2 = key2; _keyCount = 2; _effect = effect;
}

/// <summary>Three-key form.</summary>
public DisposableEffect(
object? key1,
object? key2,
object? key3,
System.Func<DisposableEffectScope, System.Action> effect)
{
System.ArgumentNullException.ThrowIfNull(effect);
_key1 = key1; _key2 = key2; _key3 = key3; _keyCount = 3; _effect = effect;
}

internal override void Render(IComposer composer)
{
switch (_keyCount)
{
case 1: Compose.DisposableEffect(_key1, _effect); break;
case 2: Compose.DisposableEffect(_key1, _key2, _effect); break;
default: Compose.DisposableEffect(_key1, _key2, _key3, _effect); break;
}
}
}
64 changes: 64 additions & 0 deletions src/ComposeNet.Compose/DisposableEffectBody.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using Android.Runtime;
using AndroidX.Compose.Runtime;
using Kotlin.Jvm.Functions;

namespace ComposeNet;

/// <summary>
/// JCW for Compose's <c>DisposableEffect</c> body —
/// <c>Function1&lt;DisposableEffectScope, DisposableEffectResult&gt;</c>.
/// Kotlin invokes the body once per (key change | enter composition)
/// and stores the returned <see cref="IDisposableEffectResult"/>; when
/// keys change or the call site leaves the composition Compose calls
/// <see cref="IDisposableEffectResult.Dispose"/> on the stored result.
/// </summary>
/// <remarks>
/// The C# body returns a <see cref="System.Action"/> "onDispose"
/// callback. We wrap that callback in a fresh
/// <see cref="ComposableLambda0"/> and hand it to
/// <see cref="DisposableEffectScope.OnDispose(IFunction0)"/>, which
/// is the only way to construct an <see cref="IDisposableEffectResult"/>
/// without subclassing the interface in Java.
/// </remarks>
[Register("composenet/compose/DisposableEffectBody")]
internal sealed class DisposableEffectBody : Java.Lang.Object, IFunction1
{
readonly System.Func<DisposableEffectScope, System.Action> _body;

public DisposableEffectBody(System.Func<DisposableEffectScope, System.Action> body)
{
_body = body;
}

public Java.Lang.Object Invoke(Java.Lang.Object? p0)
{
// Compose always passes a non-null DisposableEffectScope (the
// singleton `InternalDisposableEffectScope`). Use JavaCast<T>
// rather than `p0 as DisposableEffectScope` because Mono.Android's
// peer cache doesn't know Kotlin's `internal object` subclasses
// implement the bound interface — a plain `as` returns null even
// when the underlying Java object does implement the interface.
if (p0 is null)
throw new System.InvalidOperationException(
"DisposableEffect body received a null DisposableEffectScope");

DisposableEffectScope scope;
try
{
scope = p0.JavaCast<DisposableEffectScope>();
}
catch (System.Exception ex)
{
throw new System.InvalidOperationException(
"DisposableEffect body could not project arg ("
+ (p0.Class?.Name ?? "<unknown>")
+ ") as DisposableEffectScope", ex);
}

var onDispose = _body(scope)
?? throw new System.InvalidOperationException(
"DisposableEffect body returned a null onDispose callback. "
+ "Return `() => { }` if there's nothing to clean up.");
return (Java.Lang.Object)scope.OnDispose(new ComposableLambda0(onDispose));
}
}
Loading
Loading