Skip to content

Android

Run the complete page example.

A .NET for Android app builds its screens from Activity, Fragment and View subclasses, plus AndroidX classes such as AppCompatActivity, RecyclerView and ViewPager. ReactiveUI.AndroidX connects those to a view model. Most of the surface lives in the core ReactiveUI package, under the same names. A classic Activity/Fragment app already needs it. ReactiveUI.AndroidX adds the AppCompat and Jetpack-flavored base classes on top. Together the two packages add:

  • base classes for activities, fragments, dialogs and preference screens, so each one is an IViewFor<TViewModel> with a ViewModel property;
  • WireUpControls, which finds every control in your layout by naming convention instead of a FindViewById call per field (Wire up controls covers it);
  • adapters that show a collection of view models in a RecyclerView or page through them in a ViewPager;
  • an activation signal tied to the Android lifecycle, for WhenActivated;
  • a main-thread sequencer pointed at the app's Looper, plus a With<Platform>() call for the app builder that registers all of the above;
  • a helper that saves state across process death;
  • a helper that binds to a service as a stream instead of a ServiceConnection callback.

The package also ships as ReactiveUI.AndroidX.Reactive, built from the same source for apps that use System.Reactive. For the packages to reference, see Installation.

The example below is a school timetable app. Its main screen lists the week's lessons in a RecyclerView and features one lesson in a compound view. It also shows a running badge and two peeks of the next lessons. Tapping through reports an absence to a bound service and shows a lesson's detail in a fragment. It then confirms the absence in a dialog, pages through the weekdays, and opens a notification-settings screen. Running it on an emulator prints the lines quoted through this page under the RxDocs logcat tag.

Namespace note

ReactiveUI.AndroidX mirrors several core ReactiveUI names: ReactiveUI.ReactiveActivity<TViewModel> sits next to ReactiveUI.AndroidX.ReactiveAppCompatActivity<TViewModel>, and both namespaces declare a ControlFetcherMixins. The example's own namespace, ReactiveUI.Documentation.PlatformAndroid, starts with ReactiveUI, so C# finds AndroidX as a nested namespace of the enclosing ReactiveUI namespace before it looks anywhere else. That lets every file on this page write AndroidX.ReactiveAppCompatActivity<TimetableViewModel>, AndroidX.ReactiveFragment<LessonViewModel> and so on, without spelling out ReactiveUI.AndroidX. in full. Do the same in your own app once its namespace starts with ReactiveUI: it disambiguates the AndroidX class from the core one of the same name, and it marks the AndroidX package at the call site.

Start ReactiveUI for AndroidX

1. Register the platform module. WithAndroidX on the app builder registers ReactiveUI.AndroidX.Registrations and points ReactiveUI's main-thread sequencer at the app's Looper through HandlerSequencer. Call this once, in your Application subclass's OnCreate, before any activity runs.

IReactiveUIBuilder builder = RxAppBuilder.CreateReactiveUIBuilder();
_ = builder.WithAndroidX().BuildApp();

TimetableLog.Info($"ReactiveUI is running on {AndroidXReactiveUIBuilderExtensions.AndroidXMainThreadScheduler.GetType().Name}.");
ReactiveUI is running on HandlerSequencer.

AndroidXReactiveUIBuilderExtensions.AndroidXMainThreadScheduler is the sequencer both calls point RxSchedulers.MainThreadScheduler at. WithAndroidXScheduler sets only that sequencer, for a host that registers the rest of the platform module another way, such as a shared multi-platform composition root.

IReactiveUIBuilder schedulerOnlyBuilder = RxAppBuilder.CreateReactiveUIBuilder().WithAndroidXScheduler();
TimetableLog.Info($"WithAndroidXScheduler configured a {schedulerOnlyBuilder.GetType().Name} to run on "
    + $"{AndroidXReactiveUIBuilderExtensions.AndroidXMainThreadScheduler.GetType().Name}.");

2. Wire up automatic state suspension. AutoSuspendHelper translates activity lifecycle callbacks into the signals suspension needs, and BundleSuspensionDriver saves and loads state from the activity bundle those callbacks carry. Save and restore state covers both below.

_autoSuspendHelper = new AutoSuspendHelper(this);
RxSuspension.SuspensionHost.CreateNewAppState = static () => new TimetableAppState();

BundleSuspensionDriver driver = new();
RxSuspension.SuspensionHost.SetupDefaultSuspendResume(driver);

TimetableLog.Info("TimetableApplication started; AutoSuspendHelper and BundleSuspensionDriver are wired up.");
TimetableApplication started; AutoSuspendHelper and BundleSuspensionDriver are wired up.

A reader who stops here can already start ReactiveUI. The rest of this page gives an activity a view model, wires its controls, and covers the platform's other Android-specific pieces.

Give an activity a view model

ReactiveUI.AndroidX.ReactiveAppCompatActivity<TViewModel> is an AppCompatActivity that already implements IViewFor<TViewModel>. MainActivity, the timetable's home screen, derives from it and assigns ViewModel after wiring its controls.

public sealed class MainActivity : AndroidX.ReactiveAppCompatActivity<TimetableViewModel>
this.WireUpControls();

PropertyInfo[] wiredMembers = this.GetWireUpMembers(ControlFetcherMixins.ResolveStrategy.Implicit);
TimetableLog.Info($"WireUpControls found {wiredMembers.Length} members to wire.");
WireUpControls found 5 members to wire.

WireUpControls finds every property that is a View subtype and assigns it to the like-named resource in the activity's layout, instead of a FindViewById call per field. Wire up controls covers the naming policy and the two other resolve strategies in depth.

AbsenceActivity, the screen that records an absence, derives instead from the plain, non-AppCompat ReactiveUI.ReactiveActivity<TViewModel>. Classic Activity support without AppCompat theming stays available directly under ReactiveUI, for a screen that does not need it.

public sealed class AbsenceActivity : ReactiveActivity<AbsenceViewModel>

ReactiveUI.AndroidX.ReactiveFragment<TViewModel>, ReactiveDialogFragment<TViewModel>, ReactivePreferenceFragment<TViewModel> and ReactiveFragmentActivity<TViewModel> follow the same pattern for a fragment, a modal dialog, a preference screen and a fragment-hosting activity that does not need full AppCompat theming. Every one of them, and both ReactiveActivity<TViewModel> variants, exposes the same ViewModel property plus the IViewFor.ViewModel explicit implementation for a caller that only knows the view model as object.

Detect activation

Every reactive Android base class on this page raises the same activation signal, through the constructor an ActivationForViewFetcher registers for the type. Activated fires when the view appears; Deactivated fires when it leaves. When activated covers building on top of these with WhenActivated. MainActivity and WeekdayPagerActivity both log directly from the raw signals; AbsenceConfirmationDialogFragment does the same from its constructor, since a dialog fragment has no OnCreate override to hook in OnCreate, and LessonDetailFragment does it from OnCreateView.

_subscriptions.Add(Activated.Subscribe(static _ => TimetableLog.Info("MainActivity activated.")));
_subscriptions.Add(Deactivated.Subscribe(static _ => TimetableLog.Info("MainActivity deactivated.")));
MainActivity activated.
_subscriptions.Add(Activated.Subscribe(static _ => TimetableLog.Info("AbsenceConfirmationDialogFragment activated.")));
_subscriptions.Add(Deactivated.Subscribe(static _ => TimetableLog.Info("AbsenceConfirmationDialogFragment deactivated.")));
AbsenceConfirmationDialogFragment activated.

Every one of these types also carries the classic reactive object surface. Changed and Changing observe property changes, and PropertyChanged and PropertyChanging are the .NET events behind them. ThrownExceptions reports errors raised inside reactive operators. SuppressChangeNotifications() pauses change notifications until its result is disposed, and AreChangeNotificationsEnabled() reports whether a suppression is active. AbsenceActivity subscribes to ThrownExceptions and suppresses notifications while it fills in a freshly-created view model from an intent extra:

_ = ThrownExceptions.Subscribe(static error => TimetableLog.Info($"AbsenceActivity reported an exception: {error.Message}"));
using (SuppressChangeNotifications())
{
    string subject = Intent?.GetStringExtra(SubjectExtra) ?? "Unknown lesson";
    ViewModel = new AbsenceViewModel { Subject = subject };
}

MainActivity subscribes to Changed and Changing too, alongside the same ThrownExceptions and SuppressChangeNotifications pattern around its own ViewModel assignment:

_subscriptions.Add(Changed.Subscribe(static change => TimetableLog.Info($"MainActivity property changed: {change.PropertyName}.")));
_subscriptions.Add(Changing.Subscribe(static change => TimetableLog.Info($"MainActivity property changing: {change.PropertyName}.")));
_subscriptions.Add(ThrownExceptions.Subscribe(static error => TimetableLog.Info($"MainActivity reported an exception: {error.Message}.")));

using (SuppressChangeNotifications())
{
    ViewModel = new TimetableViewModel();
}

LessonViewHolder shows AreChangeNotificationsEnabled() alongside the rest of the surface, reading it both while a suppression is active and after it ends:

using (SuppressChangeNotifications())
{
    TimetableLog.Info($"Change notifications enabled while suppressed: {AreChangeNotificationsEnabled()}.");
}

TimetableLog.Info($"Change notifications enabled once the suppression ends: {AreChangeNotificationsEnabled()}.");
Change notifications enabled while suppressed: False.
Change notifications enabled once the suppression ends: True.

Every other reactive Android base class on this page (ReactiveDialogFragment, ReactiveFragment, ReactiveFragmentActivity and ReactivePreferenceFragment) carries the identical surface, inherited from the same IReactiveObject and IHandleObservableErrors interfaces; the app does not repeat the same four subscriptions on every one of them.

Move between activities and read the result

StartActivityForResultAsync replaces the classic StartActivityForResult plus an OnActivityResult override with a Task your calling code can await. It comes in two overloads, one that takes a ready Intent and one that takes the target Type and builds the Intent itself. MainActivity uses both to report an absence, then awaits the weekday pager the same way:

Intent absenceIntent = new(this, typeof(AbsenceActivity));
absenceIntent.PutExtra(AbsenceActivity.SubjectExtra, firstLesson.Subject);
(Android.App.Result Result, Intent? Intent) absenceResult = await StartActivityForResultAsync(absenceIntent, 100);
TimetableLog.Info($"Absence report (by intent) finished with {absenceResult.Result}: "
    + $"{absenceResult.Intent?.GetStringExtra(AbsenceActivity.SubjectExtra)}.");
LessonViewModel secondLesson = ViewModel.Lessons[1];
(Android.App.Result Result, Intent? Intent) secondAbsenceResult = await StartActivityForResultAsync(typeof(AbsenceActivity), 101);
TimetableLog.Info($"Absence report (by type) finished with {secondAbsenceResult.Result}.");

ActivityResult is an observable of every result the activity receives, alongside the Task-based calls above. MainActivity and WeekdayPagerActivity both log from it directly:

_subscriptions.Add(ActivityResult.Subscribe(static result =>
    TimetableLog.Info($"MainActivity received an activity result: {result.Result}.")));
MainActivity received an activity result: Ok.

ReactiveUI.ReactiveActivity<TViewModel> carries its own ActivityResult and StartActivityForResultAsync, separate from the AndroidX base class's members above. AbsenceActivity uses both overloads to start AbsenceNotePromptActivity, a small screen offering to add a note to the report, and subscribes to its own ActivityResult:

_ = ActivityResult.Subscribe(static result =>
    TimetableLog.Info($"AbsenceActivity's own ActivityResult observed request {result.RequestCode}: {result.ResultCode}."));

Intent notePromptIntent = new(this, typeof(AbsenceNotePromptActivity));
notePromptIntent.PutExtra(SubjectExtra, ViewModel!.Subject);
(Android.App.Result ResultCode, Intent? Intent) notePromptByIntent = await StartActivityForResultAsync(notePromptIntent, 400);
TimetableLog.Info($"Note prompt (by intent) finished with {notePromptByIntent.ResultCode}.");

(Android.App.Result ResultCode, Intent? Intent) notePromptByType = await StartActivityForResultAsync(typeof(AbsenceNotePromptActivity), 401);
TimetableLog.Info($"Note prompt (by type) finished with {notePromptByType.ResultCode}.");
AbsenceActivity's own ActivityResult observed request 400: Ok.
Note prompt (by intent) finished with Ok.
AbsenceActivity's own ActivityResult observed request 401: Ok.
Note prompt (by type) finished with Ok.

Only the activity base classes carry ActivityResult and StartActivityForResultAsync; the fragment and dialog base classes do not navigate between activities.

Host a view without an activity or fragment

LayoutViewHost inflates a layout into a View your code owns directly, without an Activity or Fragment around it. ReactiveViewHost<TViewModel> adds a ViewModel property on top, for a host that follows a view model. Both classes wire their children by hand, with no reflection, so a host stays safe to trim and to compile ahead of time. Each has an Unsafe twin that adds the reflection-based auto-wireup constructor instead:

AOT-safe typeUnsafe twinWhat the twin adds
LayoutViewHostLayoutViewHostUnsafeA constructor that takes performAutoWireup and resolveStrategy, and wires each property to the control with the matching resource ID.
ReactiveViewHost<TViewModel>ReactiveViewHostUnsafe<TViewModel>The same constructor. It also fills AllPublicProperties for older code that reads it.

Auto-wireup finds the host's properties and the app's resource IDs by reflection, which the trimmer cannot follow. So both twins are marked [RequiresUnreferencedCode] and [RequiresDynamicCode]. Prefer the bind callback shown next.

WeekdayViewHost, one page of the weekday pager, wires its label through the bind callback constructor. It passes bind: static (host, view) => ((WeekdayViewHost)host)._weekdayLabel = view.FindViewById<TextView>(Resource.Id.weekdayLabel) to the base constructor, which runs that callback once the inflated view is assigned and needs no reflection. The constructor body then subscribes to the host's own ViewModel:

{
    this.WhenAnyValue(host => host.ViewModel).Subscribe(weekday =>
        _weekdayLabel!.Text = weekday is null ? null : $"{weekday.Name} ({weekday.LessonCount} lessons)");
}

LessonPeekHost, the one-line lesson preview next to the badge on the main screen, instead reads View itself once the base constructor returns, through the plain 3- and 4-argument constructors. The only difference between them is attachToRoot: the 3-argument form defaults it to false, so the caller decides where to add the inflated view, while the 4-argument form lets the caller inflate straight into the parent. Both bodies read the label back the same way:

{
    _subjectLabel = View!.FindViewById<TextView>(Resource.Id.peekSubjectLabel)!;
    Hook();
}

MainActivity uses both constructors: it calls AddView itself for the second lesson's peek, and lets the third lesson's peek inflate directly into its row.

LessonPeekHost secondLessonPeek = new(this, PeeksRow!) { ViewModel = ViewModel.Lessons[1] };
PeeksRow!.AddView(secondLessonPeek.View);

_ = new LessonPeekHost(this, PeeksRow!, attachToRoot: true) { ViewModel = ViewModel.Lessons[2] };

The first lesson's peek, FirstLessonPeekHost, derives from ReactiveViewHostUnsafe<LessonViewModel> instead. Its constructor passes performAutoWireup: true, so auto-wireup fills its PeekSubjectLabel property from the peekSubjectLabel resource ID, with no FindViewById call:

public sealed class FirstLessonPeekHost : ReactiveViewHostUnsafe<LessonViewModel>
{
    public FirstLessonPeekHost(Context context, ViewGroup parent)
        : base(
            context,
            Resource.Layout.lesson_peek,
            parent,
            attachToRoot: false,
            performAutoWireup: true,
            resolveStrategy: ControlFetcherMixins.ResolveStrategy.Implicit) =>
        this.WhenAnyValue(host => host.ViewModel)
            .Subscribe(lesson => PeekSubjectLabel!.Text = lesson is null ? null : $"First: {lesson.Subject}");

    public TextView? PeekSubjectLabel { get; set; }
}

The class carries [RequiresUnreferencedCode] and [RequiresDynamicCode] itself, so the warning moves to the code that creates it. MainActivity adds it to the row like any other host:

// Auto-wireup fills PeekSubjectLabel by name, so this peek's constructor needs no FindViewById call.
FirstLessonPeekHost firstLessonPeek = new(this, PeeksRow!) { ViewModel = ViewModel.Lessons[0] };
PeeksRow!.AddView(firstLessonPeek.View);
TimetableLog.Info($"Auto-wired peek shows: {firstLessonPeek.PeekSubjectLabel?.Text}.");
Auto-wired peek shows: First: Mathematics.

LessonCountBadgeHost, the lesson-count badge, has no view model, since it only renders a count once. It derives from LayoutViewHostUnsafe and passes attachToRoot: false, performAutoWireup: true and resolveStrategy: ControlFetcherMixins.ResolveStrategy.Implicit to the base constructor, wiring BadgeText by name the same way. Its own body adds nothing further.

ToView() returns the host's backing View, ready to add to a layout:

public View ToBadgeView() => ToView()!;

ViewMixins.GetViewHost and GetViewHost<T> read the host tagged onto a View back off it. The weekday pager tags each page this way as it creates it, then reads the tag straight back to show both overloads:

WeekdayViewHost host = new(this, parent);

// LayoutViewHost's implicit operator to View, used here instead of the ToView() alternate.
View? convertedView = host;
View view = convertedView ?? throw new InvalidOperationException("WeekdayViewHost converted to a null View.");

WeekdayViewHost? typedHost = view.GetViewHost<WeekdayViewHost>();
ILayoutViewHost? untypedHost = view.GetViewHost();
TimetableLog.Info($"Weekday page tagged: typed={typedHost is not null}, untyped={untypedHost is not null}.");

LayoutViewHost also converts to View? implicitly, the way this excerpt does instead of calling ToView(); both read the same backing field.

Show a list with RecyclerView

ReactiveRecyclerViewAdapter<TViewModel, TCollection> adapts a collection of view models to a RecyclerView. LessonsRecyclerAdapter binds the timetable's lessons this way, and only has to implement OnCreateViewHolder:

public sealed class LessonsRecyclerAdapter(ObservableCollection<LessonViewModel> lessons) : ReactiveRecyclerViewAdapter<LessonViewModel, ObservableCollection<LessonViewModel>>(lessons)
{
    /// <summary>The view type for a lesson held in the science lab, room 7.</summary>
    private const int LabViewType = 1;

    /// <summary>The view type for every other lesson.</summary>
    private const int StandardViewType = 0;

    public override int GetItemViewType(int position, LessonViewModel? viewModel) =>
        viewModel?.Room == "7" ? LabViewType : StandardViewType;

    /// <inheritdoc/>
    public override RecyclerView.ViewHolder OnCreateViewHolder(ViewGroup parent, int viewType)
    {
        ArgumentNullException.ThrowIfNull(parent);

        LayoutInflater inflater = LayoutInflater.From(parent.Context)
            ?? throw new InvalidOperationException("No LayoutInflater is available for this parent.");
        View itemView = inflater.Inflate(Resource.Layout.lesson_item, parent, false)
            ?? throw new InvalidOperationException("Inflating lesson_item produced no view.");

        if (viewType == LabViewType)
        {
            TimetableLog.Info("Lesson row created for the science lab (room 7).");
        }

        return new LessonViewHolder(itemView);
    }
}
Lesson row created for the science lab (room 7).

GetItemViewType(int, TViewModel?) is the overload to override: it gets the position and the view model already resolved, so a view type can depend on the view model's own data. RecyclerView itself calls the non-generic GetItemViewType(int) while it lays out and recycles rows; the base class implements that overload once, to resolve the view model at the given position and forward to the one above, so app code never overrides it directly. ItemCount, inherited from RecyclerView.Adapter, reports how many rows the adapter currently holds:

LessonsRecyclerAdapter lessonsAdapter = new(ViewModel.Lessons);
LessonsRecyclerView!.SetAdapter(lessonsAdapter);
TimetableLog.Info($"Lessons adapter reports {lessonsAdapter.ItemCount} items.");
Lessons adapter reports 6 items.

ReactiveRecyclerViewAdapter<TViewModel> is the lower-level, single-generic form: it takes an IObservable<IReactiveChangeSet<TViewModel>> directly instead of a collection, for pages built from a query or a filter rather than a plain list. LessonViewHolder, the row this adapter creates, derives from ReactiveRecyclerViewViewHolder<TViewModel>, wires its own labels, and reports selection and attachment. Its constructor passes itemView straight to the base constructor, then does the rest of its work in its body:

{
    // Implicit strategy: any writable View-typed property (SubjectLabel, RoomLabel) is wired to the
    // like-named resource in the inflated row layout.
    this.WireUpControls();

    _subscriptions.Add(this.WhenAnyValue(holder => holder.ViewModel).Subscribe(lesson =>
    {
        SubjectLabel!.Text = lesson?.Subject;
        RoomLabel!.Text = lesson is null ? null : $"Room {lesson.Room}";
    }));

    _subscriptions.Add(Selected.Subscribe(static position => TimetableLog.Info($"Lesson row {position} tapped.")));
    _subscriptions.Add(SelectedWithViewModel.Subscribe(static lesson => TimetableLog.Info($"Selected lesson: {lesson?.Subject}.")));
    _subscriptions.Add(LongClicked.Subscribe(static position => TimetableLog.Info($"Lesson row {position} long-clicked.")));
    _subscriptions.Add(LongClickedWithViewModel.Subscribe(static lesson => TimetableLog.Info($"Long-clicked lesson: {lesson?.Subject}.")));
    _subscriptions.Add(Activated.Subscribe(static _ => TimetableLog.Info("Lesson row attached to the window.")));
    _subscriptions.Add(Deactivated.Subscribe(static _ => TimetableLog.Info("Lesson row detached from the window.")));
}

Selected reports the row's adapter position when it is tapped; SelectedWithViewModel reports the view model it holds at that moment instead. LongClicked and LongClickedWithViewModel report the same two shapes for a long press. The constructor also reads the holder's own View, the inherited LayoutViewHost.View. It shows the rest of the reactive object surface too, the same surface covered under Detect activation above: Changed, Changing, ThrownExceptions, SuppressChangeNotifications() and AreChangeNotificationsEnabled().

TimetableLog.Info($"Lesson row view: {View.GetType().Name}.");

AllPublicProperties, a protected property the reflection-based *Unsafe hosts fill for older code that reads it, stays unused here: LessonViewHolder wires its controls through the ordinary WireUpControls() call above, which needs no such cache. The holder's [DynamicallyAccessedMembers] annotation tells the trimmer to keep its public properties regardless, so it stays safe to trim.

Page through view models

ReactiveUI.AndroidX.ReactivePagerAdapter<TViewModel, TCollection> pages a collection of view models through a ViewPager, one page per view model. WeekdayPagerActivity builds one over the timetable's weekday summaries:

AndroidX.ReactivePagerAdapter<WeekdayViewModel, ObservableCollection<WeekdayViewModel>> adapter = new(
    weekdays,
    CreatePage,
    static (weekday, _) => TimetableLog.Info($"Weekday page created for {weekday.Name}."));
Weekday page created for Monday.

CreatePage returns one inflated WeekdayViewHost per weekday; the pager assigns its ViewModel once the page is placed. pager.Adapter = adapter installs it, and Count reports how many pages it holds:

pager.Adapter = adapter;
pager.PageSelected += (_, e) =>
{
    WeekdayViewModel weekday = weekdays[e.Position];
    ViewModel!.CurrentWeekday = weekday.Name;
    TimetableLog.Info($"Weekday pager moved to {weekday.Name}.");
};

TimetableLog.Info($"Weekday pager adapter reports {adapter.Count} pages.");
Weekday pager adapter reports 5 pages.
Weekday pager moved to Tuesday.

InstantiateItem, DestroyItem and IsViewFromObject are the PagerAdapter overrides that do this work. AndroidX's ViewPager calls them internally as pages scroll into and out of range. WeekdayPagerActivity never calls them directly; it only builds the adapter and hands it to pager.Adapter.

ReactivePagerAdapter<TViewModel> is the single-generic form: it pages any IObservable<IReactiveChangeSet<TViewModel>>, the same change-set stream ToReactiveChangeSet() produces from a collection, rather than requiring the collection itself.

using (AndroidX.ReactivePagerAdapter<WeekdayViewModel> fromChangeSet = new(weekdays.ToReactiveChangeSet(), CreatePage))
{
    TimetableLog.Info($"Change-set-backed pager adapter also reports {fromChangeSet.Count} pages.");
}

Show a dialog

ReactiveUI.AndroidX.ReactiveDialogFragment<TViewModel> is a DialogFragment with a ViewModel property. AbsenceConfirmationDialogFragment reads its view model in OnCreateDialog to build the alert:

public override Dialog OnCreateDialog(Bundle? savedInstanceState)
{
    string subject = ViewModel?.Subject ?? "this lesson";

    return new global::AndroidX.AppCompat.App.AlertDialog.Builder(RequireContext()!)
        .SetTitle("Confirm absence")!
        .SetMessage($"Mark the student absent from {subject}?")!
        .SetPositiveButton("Confirm", (_, _) => TimetableLog.Info($"Absence confirmed for {subject}."))!
        .SetNegativeButton("Cancel", static (_, _) => TimetableLog.Info("Absence confirmation cancelled."))!
        .Create()!;
}

MainActivity shows it modally by tag, the way any DialogFragment is shown, and dismisses it after a short wait:

const string ConfirmationTag = "confirm-absence";
new AbsenceConfirmationDialogFragment { ViewModel = secondLesson }.Show(SupportFragmentManager, ConfirmationTag);
await Task.Delay(TimeSpan.FromMilliseconds(200));
(SupportFragmentManager!.FindFragmentByTag(ConfirmationTag) as AbsenceConfirmationDialogFragment)?.Dismiss();

Read shared preferences

ReactiveUI.AndroidX.ReactivePreferenceFragment<TViewModel> is a PreferenceFragmentCompat with a ViewModel property. SettingsPreferenceFragment assigns its view model and loads the preference screen from a resource, the same way a plain PreferenceFragmentCompat does:

public override void OnCreatePreferences(Bundle? savedInstanceState, string? rootKey)
{
    ViewModel = new SettingsViewModel();
    SetPreferencesFromResource(Resource.Xml.settings_preferences, rootKey);

SharedPreferencesExtensions.PreferenceChanged() turns ISharedPreferences's change listener into a stream of the changed key, instead of a listener object your code registers and unregisters by hand:

ISharedPreferences? sharedPreferences = PreferenceManager?.SharedPreferences;
if (sharedPreferences is null)
{
    return;
}

_preferenceChangedSubscription = sharedPreferences.PreferenceChanged()
    .Subscribe(static key => TimetableLog.Info($"Preference changed: {key}."));

MainActivity shows the settings screen the same way it showed the lesson detail fragment, by replacing the fragment in DetailContainer:

SettingsPreferenceFragment settingsFragment = new();
SupportFragmentManager!.BeginTransaction()!
    .Replace(DetailContainer!.Id, settingsFragment)!
    .CommitNowAllowingStateLoss();
TimetableLog.Info("Showing notification settings.");
Showing notification settings.

Bind to a service as a stream

ContextExtensions.ServiceBound binds an Android service and exposes its binder as a stream, instead of a ServiceConnection your code implements and unregisters. The generic overload casts the binder to TBinder; the non-generic overload exposes the raw IBinder. Both come in a Bind-flags form and a form that defaults to Bind.None, which only connects to a service that is already running rather than starting one.

AbsenceActivity binds AttendanceTrackerService with Bind.AutoCreate first, since the service is not already running and needs to be started:

Intent attendanceIntent = new(this, typeof(AttendanceTrackerService));
_serviceSubscriptions.Add(this.ServiceBound<AttendanceTrackerService.AttendanceBinder>(attendanceIntent, Bind.AutoCreate)
    .Subscribe(
        binder =>
        {
            if (binder is null)
            {
                return;
            }

            binder.Service.RecordAbsence(ViewModel!.Subject);
            ViewModel.Reported = true;
            StatusLabel!.Text = $"Reported: {ViewModel.Subject}.";
            AbsenceStatusView!.SetReported(true);
        },
        static error => TimetableLog.Info($"Attendance service binding failed: {error.Message}")));
AttendanceTrackerService recorded an absence from Mathematics.

Once that bind has had a moment to start the service, the two Bind.None overloads can connect to it, because it is now already running:

_serviceSubscriptions.Add(this.ServiceBound<AttendanceTrackerService.AttendanceBinder>(attendanceIntent)
    .Subscribe(static binder => TimetableLog.Info($"Generic (Context,Intent) ServiceBound connected: {binder is not null}.")));
_serviceSubscriptions.Add(this.ServiceBound(attendanceIntent)
    .Subscribe(static binder => TimetableLog.Info($"Non-generic (Context,Intent) ServiceBound connected: {binder is not null}.")));

A second, unrelated service, SchoolBellService, is bound with an explicit Bind flag through the non-generic, raw-IBinder overload:

Intent bellIntent = new(this, typeof(SchoolBellService));
_serviceSubscriptions.Add(this.ServiceBound(bellIntent, Bind.AutoCreate)
    .Subscribe(static binder => TimetableLog.Info($"SchoolBellService bound via (Context,Intent,Bind): {binder is not null}.")));

Both services never leave the app's own process, so binding to either always succeeds without a system permission prompt.

Save and restore state across process death

AutoSuspendHelper and BundleSuspensionDriver were wired up in Start ReactiveUI for AndroidX. AutoSuspendHelper watches every activity's lifecycle callbacks and keeps the static LatestBundle current, so BundleSuspensionDriver always has the bundle Android most recently handed to OnSaveInstanceState. Reading it tells a cold launch apart from a process recreation:

TimetableLog.Info($"AutoSuspendHelper's latest bundle is currently {(AutoSuspendHelper.LatestBundle is null ? "unset" : "set")}.");
AutoSuspendHelper's latest bundle is currently unset.

SaveState<T>(T, JsonTypeInfo<T>) and LoadState<T>(JsonTypeInfo<T>) are the trim- and AOT-safe overloads, through a source-generated JsonTypeInfo<T>. TimetableApplication builds one for TimetableAppState and uses both, plus InvalidateState to clear the saved bundle:

_ = driver.SaveState(new TimetableAppState { LastViewedSubject = "Mathematics" }, TimetableAppStateJsonContext.Default.TimetableAppState)
    .Subscribe(static _ => TimetableLog.Info("BundleSuspensionDriver saved the app state."));
_ = driver.LoadState(TimetableAppStateJsonContext.Default.TimetableAppState)
    .Subscribe(
        static state => TimetableLog.Info($"BundleSuspensionDriver loaded state for {state?.LastViewedSubject}."),
        static error => TimetableLog.Info($"BundleSuspensionDriver had nothing to load yet: {error.Message}"));
_ = driver.InvalidateState().Subscribe(static _ => TimetableLog.Info("BundleSuspensionDriver invalidated the saved state."));
BundleSuspensionDriver saved the app state.
BundleSuspensionDriver had nothing to load yet: New bundle detected; no persisted state is available.
BundleSuspensionDriver invalidated the saved state.

LoadState fails here because this call runs on the same cold launch that just saved the state: AutoSuspendHelper hasn't seen an OnSaveInstanceState callback yet, so LatestBundle is still the one from before SaveState ran. A real app calls LoadState/LoadState<T> once, after a process recreation, to read back what an earlier process saved. LoadState() and SaveState<T>(T) are the untyped overloads: they serialize through reflection instead of a source-generated JsonTypeInfo<T>, so a page project built for trimming and AOT never calls them directly.

UntimelyDemise is a signal that fires from an unhandled exception. SetupDefaultSuspendResume treats that signal as a reason to invalidate the saved state rather than trust it on the next launch; TimetableApplication also logs from it directly:

_ = AutoSuspendHelper.UntimelyDemise.Subscribe(static _ => TimetableLog.Info("AutoSuspendHelper reported an untimely demise."));

Nothing in this app's own scenario throws unhandled, so this line only appears in adb logcat if something else does.

Read the device orientation

PlatformOperations answers the same GetOrientation question every platform's IPlatformOperations answers. On Android it reads the default display's current rotation from the display manager and returns the rotation's name. Rotation0 is the device's natural orientation, and Rotation90, Rotation180 and Rotation270 are the turns away from it:

PlatformOperations platformOperations = new();
string? orientation = platformOperations.GetOrientation();
TimetableLog.Info($"Device orientation: {orientation}.");
Device orientation: Rotation0.

USB permission requests

UsbManagerExtensions.PermissionRequested turns a USB permission dialog into a stream of the granted result, for either a UsbDevice or a UsbAccessory, instead of a BroadcastReceiver your code registers and unregisters. Some timetable devices use a USB barcode scanner to check students in, so MainActivity asks for permission to talk to any USB device or accessory already plugged in when it starts:

private void RequestAttendanceScannerPermission()
{
    if (GetSystemService(UsbService) is not UsbManager usbManager)
    {
        return;
    }

    foreach (UsbDevice device in usbManager.DeviceList?.Values ?? [])
    {
        _subscriptions.Add(usbManager.PermissionRequested(this, device)
            .Subscribe(granted => TimetableLog.Info($"USB device {device.DeviceName} permission granted: {granted}.")));
    }

    foreach (UsbAccessory accessory in usbManager.GetAccessoryList() ?? [])
    {
        _subscriptions.Add(usbManager.PermissionRequested(this, accessory)
            .Subscribe(granted => TimetableLog.Info($"USB accessory {accessory.Model} permission granted: {granted}.")));
    }
}

Neither loop finds a device on a plain emulator, so this method logs nothing unless a scanner is attached. A physical or emulated USB device is still needed to see the granted result itself.

At a glance

MemberWhat it does
AndroidXReactiveUIBuilderExtensions.WithAndroidX()Registers the AndroidX platform module and its main-thread sequencer
AndroidXReactiveUIBuilderExtensions.WithAndroidXScheduler()Sets only the main-thread sequencer
AndroidXReactiveUIBuilderExtensions.AndroidXMainThreadSchedulerThe shared HandlerSequencer for the app's Looper
ReactiveUI.AndroidX.RegistrationsThe module WithAndroidX loads: core Android platform registrations, plus a Looper for the app if needed
ReactiveActivity / ReactiveActivity<TViewModel>A classic Activity that is an IViewFor<TViewModel>, without AppCompat
AndroidX.ReactiveAppCompatActivity / ReactiveAppCompatActivity<TViewModel>An AppCompatActivity that is an IViewFor<TViewModel>
AndroidX.ReactiveFragmentActivity / ReactiveFragmentActivity<TViewModel>A fragment-hosting activity that is an IViewFor<TViewModel>, without full AppCompat theming
AndroidX.ReactiveFragment / ReactiveFragment<TViewModel>A Fragment that is an IViewFor<TViewModel>
AndroidX.ReactiveDialogFragment / ReactiveDialogFragment<TViewModel>A DialogFragment that is an IViewFor<TViewModel>
AndroidX.ReactivePreferenceFragment / ReactivePreferenceFragment<TViewModel>A PreferenceFragmentCompat that is an IViewFor<TViewModel>
Activated / DeactivatedFire when the view appears and leaves; feed WhenActivated
Changed / Changing / PropertyChanged / PropertyChangingObserve and raise property changes, as on any ReactiveObject
ThrownExceptionsReports errors raised inside reactive operators
SuppressChangeNotifications()Pauses change notifications until the result is disposed
AreChangeNotificationsEnabled()Reports whether a SuppressChangeNotifications() suppression is currently active
ActivityResultAn observable of every activity result received; both ReactiveActivity<TViewModel> and the AndroidX activity base classes carry their own
StartActivityForResultAsync(Intent, int) / StartActivityForResultAsync(Type, int)Starts an activity and awaits its result as a Task
ControlFetcherMixins.WireUpControls(...) (both namespaces)Finds controls in a layout by naming convention; see Wire up controls
ControlFetcherMixins.GetControl(...)Fetches one control by resource name without a property
ControlFetcherMixins.GetWireUpMembers(object, ResolveStrategy)Lists the properties a resolve strategy would wire
ControlFetcherMixins.GetResourceName(PropertyInfo)Reads the resource name a property wires to
ControlFetcherMixins.ResolveStrategyImplicit, ExplicitOptIn and ExplicitOptOut: which properties WireUpControls wires
WireUpResourceAttributeOpts a property in under ExplicitOptIn, with an optional resource name override
WireUpResourceAttribute.ResourceNameOverrideThe resource name the constructor argument set, or null
IgnoreResourceAttributeOpts a property out under ExplicitOptOut
LayoutViewHostInflates a layout into a View your code owns, wiring its children by hand with no reflection
LayoutViewHost.implicit operator View?(LayoutViewHost)Converts a host to its backing View, the same value ToView() returns
LayoutViewHostUnsafeA LayoutViewHost whose constructor can wire its children by reflection (auto-wireup)
ReactiveViewHost<TViewModel>A LayoutViewHost that is also an IViewFor<TViewModel>
ReactiveViewHostUnsafe<TViewModel>A ReactiveViewHost<TViewModel> whose constructor can wire its children by reflection (auto-wireup)
ILayoutViewHostThe View-holding interface both hosts implement
ViewMixins.GetViewHost() / GetViewHost<T>()Reads the host a View was tagged with when it was hosted
AndroidX.ReactiveRecyclerViewAdapter<TViewModel, TCollection>Adapts a collection of view models to a RecyclerView
AndroidX.ReactiveRecyclerViewAdapter<TViewModel>Adapts a change-set stream of view models to a RecyclerView
GetItemViewType(int, TViewModel?)Override to pick a view type from the position and its view model
GetItemViewType(int)The non-generic override RecyclerView calls; resolves the view model and forwards to the overload above
ItemCountThe number of rows the adapter currently holds
AndroidX.ReactiveRecyclerViewViewHolder<TViewModel>A RecyclerView.ViewHolder that is an IViewFor<TViewModel>, with Selected, SelectedWithViewModel, LongClicked and LongClickedWithViewModel
AndroidX.ReactivePagerAdapter<TViewModel, TCollection>Pages a collection of view models through a ViewPager
AndroidX.ReactivePagerAdapter<TViewModel>Pages a change-set stream of view models through a ViewPager
InstantiateItem(ViewGroup, int) / DestroyItem(ViewGroup, int, Object) / IsViewFromObject(View, Object)PagerAdapter overrides ViewPager calls internally as pages scroll into and out of range
ContextExtensions.ServiceBound(...) / ServiceBound<TBinder>(...)Binds a service and exposes its binder as a stream
AutoSuspendHelperTurns activity lifecycle callbacks into suspend/resume signals
AutoSuspendHelper.LatestBundleThe bundle from the most recent OnSaveInstanceState, or null on a cold launch
AutoSuspendHelper.UntimelyDemiseFires once from an unhandled exception
BundleSuspensionDriver.SaveState<T>(T, JsonTypeInfo<T>) / LoadState<T>(JsonTypeInfo<T>)Trim- and AOT-safe save and load through a source-generated JsonTypeInfo<T>
BundleSuspensionDriver.InvalidateState()Clears the saved state
BundleSuspensionDriver.SaveState<T>(T) / LoadState()The untyped, reflection-based overloads; not callable from a trimmed or AOT page project
PlatformOperations.GetOrientation()Returns the display's current rotation, such as "Rotation0"
SharedPreferencesExtensions.PreferenceChanged()A stream of the key each changed shared preference used
UsbManagerExtensions.PermissionRequested(...)A stream of the granted result for a USB device or accessory permission request