diff --git a/src/Directory.Build.props b/src/Directory.Build.props index f35492fb05..75ac8a0949 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -206,7 +206,7 @@ - + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index d12241609c..4d297703c3 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -7,7 +7,7 @@ 21.0.0 8.2.0 - 8.4.0 + 8.5.0 1.69.16 diff --git a/src/examples/Directory.Build.props b/src/examples/Directory.Build.props index 25d87170fd..82d2a58426 100644 --- a/src/examples/Directory.Build.props +++ b/src/examples/Directory.Build.props @@ -6,6 +6,12 @@ the root defaults the value and derives $(EnablePublicApiBaseline) and the analyzer reference from it, so setting it afterwards leaves tracking half on. --> false + + + false + false @@ -14,5 +20,7 @@ false 14.0 + false + false diff --git a/src/examples/Documentation/Pages/commands/BookReceiptPrinter.cs b/src/examples/Documentation/Pages/commands/BookReceiptPrinter.cs new file mode 100644 index 0000000000..2160d9bef0 --- /dev/null +++ b/src/examples/Documentation/Pages/commands/BookReceiptPrinter.cs @@ -0,0 +1,22 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Documentation.Commands; + +/// +/// A receipt printer that watches a borrow command's results directly, by implementing +/// itself, rather than through an operator such as Subscribe(Action<T>). +/// +public sealed class BookReceiptPrinter : IObserver +{ + /// + public void OnNext(Book value) => Console.WriteLine($"Printed a receipt for {value.Title}"); + + /// + public void OnError(Exception error) => Console.WriteLine($"Printer jammed: {error.Message}"); + + /// + public void OnCompleted() => Console.WriteLine("No more receipts to print"); +} diff --git a/src/examples/Documentation/Pages/commands/CommandTypeExamples.cs b/src/examples/Documentation/Pages/commands/CommandTypeExamples.cs index 4621a7a50e..4dc5e3aeb9 100644 --- a/src/examples/Documentation/Pages/commands/CommandTypeExamples.cs +++ b/src/examples/Documentation/Pages/commands/CommandTypeExamples.cs @@ -125,6 +125,48 @@ public static async Task DeriveFromCommandBase() // cannot announce } + /// + /// A witness that implements directly, such as , can + /// subscribe to a command's results the same way any other observer does. + /// + /// A task that completes once both books have been borrowed. + public static async Task SubscribeAWitnessToACommandsResults() + { + LibraryDesk desk = new(); + BookReceiptPrinter witness = new(); + + using ReactiveCommand borrow = ReactiveCommand.Create(desk.Borrow); + using IDisposable subscription = borrow.Subscribe(witness); + + _ = await borrow.Execute(1); + _ = await borrow.Execute(4); + + // Output: + // Printed a receipt for Clean Code + // Printed a receipt for Refactoring + } + + /// + /// Subscribing a witness through works whether the command was + /// built with a factory or, like , by hand; a display board only needs to + /// know a command produces string results, not which subclass built it. + /// + /// A task that completes once the announcement has posted to the board. + public static async Task SubscribeAWitnessThroughTheBaseType() + { + LibraryDesk desk = new(); + DeskAnnouncementBoard board = new(); + using DeskAnnouncementCommand closingSoon = new(desk, static () => "The desk closes in ten minutes."); + + ReactiveCommandBase command = closingSoon; + using IDisposable subscription = command.Subscribe(board); + + _ = await command.Execute(); + + // Output: + // Board: The desk closes in ten minutes. + } + /// /// Write your own command type from CombinedReactiveCommand<TParam, TResult>'s three constructors /// when every combined command of a kind needs the same behaviour on each run; diff --git a/src/examples/Documentation/Pages/commands/DeskAnnouncementBoard.cs b/src/examples/Documentation/Pages/commands/DeskAnnouncementBoard.cs new file mode 100644 index 0000000000..6995ae049b --- /dev/null +++ b/src/examples/Documentation/Pages/commands/DeskAnnouncementBoard.cs @@ -0,0 +1,22 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Documentation.Commands; + +/// +/// A display board that posts each announcement it receives directly, by implementing +/// itself. It only needs a command that produces string results, not any particular command subclass. +/// +public sealed class DeskAnnouncementBoard : IObserver +{ + /// + public void OnNext(string value) => Console.WriteLine($"Board: {value}"); + + /// + public void OnError(Exception error) => Console.WriteLine($"Board offline: {error.Message}"); + + /// + public void OnCompleted() => Console.WriteLine("Board cleared"); +} diff --git a/src/examples/Documentation/Pages/commands/Program.cs b/src/examples/Documentation/Pages/commands/Program.cs index 57d01aa20a..0e1311c286 100644 --- a/src/examples/Documentation/Pages/commands/Program.cs +++ b/src/examples/Documentation/Pages/commands/Program.cs @@ -74,6 +74,10 @@ await CommandTypeExamples.DeriveFromCommandBase(); +await CommandTypeExamples.SubscribeAWitnessToACommandsResults(); + +await CommandTypeExamples.SubscribeAWitnessThroughTheBaseType(); + await CommandTypeExamples.DeriveCombinedCommand(); await CombinedCommandMembersExamples.ReadCombinedCommandMembersDirectly(); diff --git a/src/examples/Documentation/Pages/platform-android/AbsenceActivity.cs b/src/examples/Documentation/Pages/platform-android/AbsenceActivity.cs index 7bda16dfd6..e47e8de277 100644 --- a/src/examples/Documentation/Pages/platform-android/AbsenceActivity.cs +++ b/src/examples/Documentation/Pages/platform-android/AbsenceActivity.cs @@ -92,6 +92,19 @@ protected override async void OnCreate(Bundle? savedInstanceState) await Task.Delay(TimeSpan.FromMilliseconds(100)); + // This activity's own ActivityResult and StartActivityForResultAsync overloads, on the plain + // ReactiveActivity base rather than the AndroidX one MainActivity uses. + _ = 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}."); + Intent resultIntent = new(); resultIntent.PutExtra(SubjectExtra, ViewModel!.Subject); SetResult(Android.App.Result.Ok, resultIntent); diff --git a/src/examples/Documentation/Pages/platform-android/AbsenceNotePromptActivity.cs b/src/examples/Documentation/Pages/platform-android/AbsenceNotePromptActivity.cs new file mode 100644 index 0000000000..5c7e80b03a --- /dev/null +++ b/src/examples/Documentation/Pages/platform-android/AbsenceNotePromptActivity.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Android.Content; + +namespace ReactiveUI.Documentation.PlatformAndroid; + +/// Asks whether to add a note to an absence report. This activity predates AndroidX, like +/// : it derives from the plain , so +/// can start it with that base's own ActivityResult and +/// StartActivityForResultAsync members rather than the AndroidX ones already +/// uses. +[Activity(Label = "Add a note")] +public sealed class AbsenceNotePromptActivity : ReactiveActivity +{ + /// + protected override async void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + SetContentView(Resource.Layout.activity_absence_note_prompt); + + string subject = Intent?.GetStringExtra(AbsenceActivity.SubjectExtra) ?? "this lesson"; + TextView promptLabel = FindViewById(Resource.Id.notePromptLabel)!; + promptLabel.Text = $"Add a note about {subject}?"; + + await Task.Delay(TimeSpan.FromMilliseconds(100)); + + Intent resultIntent = new(); + resultIntent.PutExtra(AbsenceActivity.SubjectExtra, subject); + SetResult(Android.App.Result.Ok, resultIntent); + Finish(); + } +} diff --git a/src/examples/Documentation/Pages/platform-android/LessonDetailFragment.cs b/src/examples/Documentation/Pages/platform-android/LessonDetailFragment.cs index a9372cfa78..864854431b 100644 --- a/src/examples/Documentation/Pages/platform-android/LessonDetailFragment.cs +++ b/src/examples/Documentation/Pages/platform-android/LessonDetailFragment.cs @@ -3,6 +3,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reflection; using Android.Views; namespace ReactiveUI.Documentation.PlatformAndroid; @@ -30,6 +31,13 @@ public sealed class LessonDetailFragment : AndroidX.ReactiveFragment(); + TimetableLog.Info($"SubjectLabel's resource name override: {subjectAttribute?.ResourceNameOverride}."); + // A control the fragment fetches directly, rather than wiring to a property. View? icon = view.GetControl(GetType().Assembly, "detailIcon"); TimetableLog.Info(icon is null ? "detailIcon was not found." : "detailIcon resolved directly with GetControl."); diff --git a/src/examples/Documentation/Pages/platform-android/LessonNotesFragment.cs b/src/examples/Documentation/Pages/platform-android/LessonNotesFragment.cs new file mode 100644 index 0000000000..fb3470c654 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-android/LessonNotesFragment.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Android.Views; + +namespace ReactiveUI.Documentation.PlatformAndroid; + +/// Shows a revision note for a lesson, hosted as an . +/// Wires its own label with the two-argument, implicit-strategy overload of WireUpControls, rather than +/// stating a strategy explicitly the way does. +[System.Diagnostics.DebuggerDisplay("{ViewModel}")] +public sealed class LessonNotesFragment : AndroidX.ReactiveFragment +{ + /// Gets or sets the label showing the revision note; wired by naming convention to notesLabel. + public TextView? NotesLabel { get; set; } + + /// + public override View? OnCreateView(LayoutInflater? inflater, ViewGroup? container, Bundle? savedInstanceState) + { + View view = inflater!.Inflate(Resource.Layout.fragment_lesson_notes, container, false) + ?? throw new InvalidOperationException("Inflating fragment_lesson_notes produced no view."); + + // The convenience overload: no resolve strategy to state, since Implicit is what most fragments want. + AndroidX.ControlFetcherMixins.WireUpControls(this, view); + + NotesLabel!.Text = ViewModel is null ? "No notes yet." : $"Revise {ViewModel.Subject} before the next lesson."; + return view; + } +} diff --git a/src/examples/Documentation/Pages/platform-android/LessonViewHolder.cs b/src/examples/Documentation/Pages/platform-android/LessonViewHolder.cs index 95f058924d..053776ee9e 100644 --- a/src/examples/Documentation/Pages/platform-android/LessonViewHolder.cs +++ b/src/examples/Documentation/Pages/platform-android/LessonViewHolder.cs @@ -36,6 +36,24 @@ public LessonViewHolder(View itemView) _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."))); + + // The IReactiveObject surface every reactive Android base class carries: Changed and Changing report every + // property this holder itself raises a notification for (here, ViewModel, whenever RecyclerView recycles + // this row onto a new item), and ThrownExceptions reports anything an OnNext handler on those streams throws. + _subscriptions.Add(Changed.Subscribe(static change => TimetableLog.Info($"Lesson row property changed: {change.PropertyName}."))); + _subscriptions.Add(Changing.Subscribe(static change => TimetableLog.Info($"Lesson row property changing: {change.PropertyName}."))); + _subscriptions.Add(ThrownExceptions.Subscribe(static error => TimetableLog.Info($"Lesson row reported an exception: {error.Message}."))); + + // SuppressChangeNotifications pauses Changed/Changing for its duration - useful when several properties + // are about to be filled in at once and only the final state matters. AreChangeNotificationsEnabled() + // reports whether a suppression is currently active. + using (SuppressChangeNotifications()) + { + TimetableLog.Info($"Change notifications enabled while suppressed: {AreChangeNotificationsEnabled()}."); + } + + TimetableLog.Info($"Change notifications enabled once the suppression ends: {AreChangeNotificationsEnabled()}."); + TimetableLog.Info($"Lesson row view: {View.GetType().Name}."); } /// Gets or sets the label showing the lesson's subject. diff --git a/src/examples/Documentation/Pages/platform-android/LessonsRecyclerAdapter.cs b/src/examples/Documentation/Pages/platform-android/LessonsRecyclerAdapter.cs index c817c2257e..2591ce52af 100644 --- a/src/examples/Documentation/Pages/platform-android/LessonsRecyclerAdapter.cs +++ b/src/examples/Documentation/Pages/platform-android/LessonsRecyclerAdapter.cs @@ -15,6 +15,20 @@ namespace ReactiveUI.Documentation.PlatformAndroid; [System.Diagnostics.DebuggerDisplay("ItemCount = {ItemCount}")] public sealed class LessonsRecyclerAdapter(ObservableCollection lessons) : ReactiveRecyclerViewAdapter>(lessons) { + /// The view type for a lesson held in the science lab, room 7. + private const int LabViewType = 1; + + /// The view type for every other lesson. + private const int StandardViewType = 0; + + /// Picks a view type for a lesson: the science lab (room 7) gets its own type, so + /// can tell a lab lesson apart from any other. + /// The position of the lesson in the list. + /// The lesson at that position, or if the position is out of range. + /// An ID identifying the view type to use for the lesson. + public override int GetItemViewType(int position, LessonViewModel? viewModel) => + viewModel?.Room == "7" ? LabViewType : StandardViewType; + /// public override RecyclerView.ViewHolder OnCreateViewHolder(ViewGroup parent, int viewType) { @@ -25,6 +39,11 @@ public override RecyclerView.ViewHolder OnCreateViewHolder(ViewGroup parent, int 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); } } diff --git a/src/examples/Documentation/Pages/platform-android/MainActivity.cs b/src/examples/Documentation/Pages/platform-android/MainActivity.cs index 3fa21a0fa9..efa1117270 100644 --- a/src/examples/Documentation/Pages/platform-android/MainActivity.cs +++ b/src/examples/Documentation/Pages/platform-android/MainActivity.cs @@ -5,6 +5,7 @@ using System.Reflection; using Android.Content; +using Android.Hardware.Usb; using AndroidX.RecyclerView.Widget; namespace ReactiveUI.Documentation.PlatformAndroid; @@ -50,7 +51,18 @@ protected override async void OnCreate(Bundle? savedInstanceState) TimetableLog.Info($" {member.Name} -> resource '{member.GetResourceName()}'."); } - ViewModel = new TimetableViewModel(); + // The IReactiveObject surface every reactive Android base class carries: Changed and Changing report every + // property change this activity itself raises (here, ViewModel), and ThrownExceptions reports anything an + // OnNext handler on those streams throws. SuppressChangeNotifications pauses Changed/Changing for its + // duration - useful while several properties are about to be filled in at once. + _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(); + } // GetOrientation() returns the display's current rotation by name, such as "Rotation0" for the natural orientation. PlatformOperations platformOperations = new(); @@ -76,7 +88,11 @@ protected override async void OnCreate(Bundle? savedInstanceState) _ = new LessonPeekHost(this, PeeksRow!, attachToRoot: true) { ViewModel = ViewModel.Lessons[2] }; LessonsRecyclerView!.SetLayoutManager(new LinearLayoutManager(this)); - LessonsRecyclerView!.SetAdapter(new LessonsRecyclerAdapter(ViewModel.Lessons)); + LessonsRecyclerAdapter lessonsAdapter = new(ViewModel.Lessons); + LessonsRecyclerView!.SetAdapter(lessonsAdapter); + TimetableLog.Info($"Lessons adapter reports {lessonsAdapter.ItemCount} items."); + + RequestAttendanceScannerPermission(); _subscriptions.Add(Activated.Subscribe(static _ => TimetableLog.Info("MainActivity activated."))); _subscriptions.Add(Deactivated.Subscribe(static _ => TimetableLog.Info("MainActivity deactivated."))); @@ -97,6 +113,28 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + /// Some timetable devices use a USB barcode scanner to check students in as they arrive. If one is + /// already plugged in, asks the system for permission to talk to it. + 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}."))); + } + } + /// Drives the whole demo end to end: pick a lesson, report an absence, show its detail, confirm the /// absence, page the weekdays, then open notification settings. /// A task that completes once every screen in the scenario has run. @@ -139,6 +177,12 @@ private async Task RunScenario() .CommitNowAllowingStateLoss(); TimetableLog.Info("Showing notification settings."); + LessonNotesFragment notesFragment = new() { ViewModel = firstLesson }; + SupportFragmentManager!.BeginTransaction()! + .Replace(DetailContainer!.Id, notesFragment)! + .CommitNowAllowingStateLoss(); + TimetableLog.Info($"Showing notes for {firstLesson.Subject}."); + TimetableLog.Info("=== Scenario complete ==="); } } diff --git a/src/examples/Documentation/Pages/platform-android/Resources/layout/activity_absence_note_prompt.xml b/src/examples/Documentation/Pages/platform-android/Resources/layout/activity_absence_note_prompt.xml new file mode 100644 index 0000000000..5047b3c000 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-android/Resources/layout/activity_absence_note_prompt.xml @@ -0,0 +1,11 @@ + + + + diff --git a/src/examples/Documentation/Pages/platform-android/Resources/layout/fragment_lesson_notes.xml b/src/examples/Documentation/Pages/platform-android/Resources/layout/fragment_lesson_notes.xml new file mode 100644 index 0000000000..e384e68fb3 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-android/Resources/layout/fragment_lesson_notes.xml @@ -0,0 +1,11 @@ + + + + diff --git a/src/examples/Documentation/Pages/platform-android/WeekdayPagerActivity.cs b/src/examples/Documentation/Pages/platform-android/WeekdayPagerActivity.cs index 108d079d1d..d328550803 100644 --- a/src/examples/Documentation/Pages/platform-android/WeekdayPagerActivity.cs +++ b/src/examples/Documentation/Pages/platform-android/WeekdayPagerActivity.cs @@ -102,7 +102,10 @@ private View CreatePage(WeekdayViewModel weekday, ViewGroup parent) { _ = weekday; WeekdayViewHost host = new(this, parent); - View view = host.ToView()!; + + // 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(); ILayoutViewHost? untypedHost = view.GetViewHost(); diff --git a/src/examples/Documentation/Pages/platform-apple/LinuxPlaceholder/Program.cs b/src/examples/Documentation/Pages/platform-apple/LinuxPlaceholder/Program.cs new file mode 100644 index 0000000000..ccb5263961 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/LinuxPlaceholder/Program.cs @@ -0,0 +1,7 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +// The iOS and macOS workloads exist only on Windows and macOS. Build this project there to compile the examples. +Console.WriteLine("The Apple examples build on Windows and macOS."); diff --git a/src/examples/Documentation/Pages/platform-apple/Shared/Book.cs b/src/examples/Documentation/Pages/platform-apple/Shared/Book.cs new file mode 100644 index 0000000000..36e41a7e41 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/Shared/Book.cs @@ -0,0 +1,40 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Documentation.PlatformApple; + +/// A book the library owns. Bound labels and controls refresh whenever or changes. +[System.Diagnostics.DebuggerDisplay("Book {Title} IsOnLoan = {IsOnLoan}")] +public sealed class Book : ReactiveObject +{ + /// Initializes a new instance of the class. + /// The book's title. + /// The book's author. + public Book(string title, string author) + { + Title = title; + Author = author; + } + + /// Gets the book's title. + public string Title { get; } + + /// Gets the book's author. + public string Author { get; } + + /// Gets or sets a value indicating whether a member currently has the book on loan. + public bool IsOnLoan + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } + + /// Gets or sets the book's star rating, from 0 to 5. + public int Rating + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/Shared/LibraryAppState.cs b/src/examples/Documentation/Pages/platform-apple/Shared/LibraryAppState.cs new file mode 100644 index 0000000000..49d6f1d1f2 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/Shared/LibraryAppState.cs @@ -0,0 +1,15 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The application state saves under Application Support and +/// restores from it, so a relaunched app picks up where the last one left off. +[System.Diagnostics.DebuggerDisplay("{LastViewedBook}")] +public sealed class LibraryAppState +{ + /// Gets or sets the title of the book the member last looked at. + public string LastViewedBook { get; set; } = string.Empty; +} diff --git a/src/examples/Documentation/Pages/platform-apple/Shared/LibraryAppStateJsonContext.cs b/src/examples/Documentation/Pages/platform-apple/Shared/LibraryAppStateJsonContext.cs new file mode 100644 index 0000000000..d7d2f88171 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/Shared/LibraryAppStateJsonContext.cs @@ -0,0 +1,15 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Text.Json.Serialization; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// Source-generated serialization metadata for , so +/// 's trim- and AOT-safe LoadState{T}(JsonTypeInfo{T}) and +/// SaveState{T}(T, JsonTypeInfo{T}) overloads never need reflection-based serialization. +[JsonSerializable(typeof(LibraryAppState))] +[System.Diagnostics.DebuggerDisplay("LibraryAppStateJsonContext")] +public sealed partial class LibraryAppStateJsonContext : JsonSerializerContext; diff --git a/src/examples/Documentation/Pages/platform-apple/Shared/Member.cs b/src/examples/Documentation/Pages/platform-apple/Shared/Member.cs new file mode 100644 index 0000000000..100d4f1544 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/Shared/Member.cs @@ -0,0 +1,12 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Documentation.PlatformApple; + +/// A library member who can borrow books. +/// The member's card number. +/// The member's name. +[System.Diagnostics.DebuggerDisplay("Member {Name} ({Id})")] +public sealed record Member(string Id, string Name); diff --git a/src/examples/Documentation/Pages/platform-apple/Shared/ViewModels/BookCatalogViewModel.cs b/src/examples/Documentation/Pages/platform-apple/Shared/ViewModels/BookCatalogViewModel.cs new file mode 100644 index 0000000000..867a0bc2de --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/Shared/ViewModels/BookCatalogViewModel.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The catalog page: every book the library owns, plus the members who can borrow one. +[System.Diagnostics.DebuggerDisplay("BookCatalogViewModel Books = {Books.Count}")] +public sealed class BookCatalogViewModel : ReactiveObject, IRoutableViewModel, IDisposable +{ + /// The members who can borrow a book, carried forward to the loan page. + private readonly IReadOnlyList _members; + + /// Initializes a new instance of the class. + /// The shell that owns the router. + /// The library's catalog. + /// The members who can borrow a book. + public BookCatalogViewModel(IScreen hostScreen, IReadOnlyList books, IReadOnlyList members) + { + HostScreen = hostScreen; + Books = books as ObservableCollection ?? new ObservableCollection(books); + _members = members; + + IObservable canLoan = this.WhenAnyValue( + static x => x.SelectedBook, + static book => book is not null && !book.IsOnLoan); + + OpenLoan = ReactiveCommand.CreateFromObservable( + () => HostScreen.Router.Navigate.Execute(new LoanViewModel(HostScreen, SelectedBook!, _members)), + canLoan); + } + + /// + public string UrlPathSegment => "catalog"; + + /// + public IScreen HostScreen { get; } + + /// Gets the library's catalog. A reactive table or collection source binds to this directly: because it + /// implements , the source refreshes the + /// view whenever a book is added or removed. + public ObservableCollection Books { get; } + + /// Gets or sets the book the member picked from the catalog. + public Book? SelectedBook + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } + + /// Gets the command that opens the loan page for . + public ReactiveCommand OpenLoan { get; } + + /// + public void Dispose() => OpenLoan.Dispose(); +} diff --git a/src/examples/Documentation/Pages/platform-apple/Shared/ViewModels/LibraryShellViewModel.cs b/src/examples/Documentation/Pages/platform-apple/Shared/ViewModels/LibraryShellViewModel.cs new file mode 100644 index 0000000000..fa46532a46 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/Shared/ViewModels/LibraryShellViewModel.cs @@ -0,0 +1,17 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The library app's shell. It owns the router that RoutedViewHost follows on iOS. +[System.Diagnostics.DebuggerDisplay("Pages = {Router.NavigationStack.Count}")] +public sealed class LibraryShellViewModel : ReactiveObject, IScreen +{ + /// + public RoutingState Router { get; } = new(); + + /// Gets the title the macOS window shows for the shell. + public string Title => "Library"; +} diff --git a/src/examples/Documentation/Pages/platform-apple/Shared/ViewModels/LoanViewModel.cs b/src/examples/Documentation/Pages/platform-apple/Shared/ViewModels/LoanViewModel.cs new file mode 100644 index 0000000000..9244cc5c5e --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/Shared/ViewModels/LoanViewModel.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The loan page: pick a member for , then confirm or cancel. +[System.Diagnostics.DebuggerDisplay("LoanViewModel Book = {Book.Title}")] +public sealed class LoanViewModel : ReactiveObject, IRoutableViewModel, IDisposable +{ + /// Initializes a new instance of the class. + /// The shell that owns the router. + /// The book being loaned. + /// The members who can borrow the book. + public LoanViewModel(IScreen hostScreen, Book book, IReadOnlyList members) + { + HostScreen = hostScreen; + Book = book; + Members = members; + + IObservable canConfirm = this.WhenAnyValue(static x => x.SelectedMember).Select(static member => member is not null); + + ConfirmLoan = ReactiveCommand.CreateFromObservable( + () => + { + Book.IsOnLoan = true; + return HostScreen.Router.NavigateBack.Execute(); + }, + canConfirm); + + Cancel = ReactiveCommand.CreateFromObservable(() => HostScreen.Router.NavigateBack.Execute()); + } + + /// + public string UrlPathSegment => "loan"; + + /// + public IScreen HostScreen { get; } + + /// Gets the book being loaned. + public Book Book { get; } + + /// Gets the members who can borrow the book. The view shows a row per member. + public IReadOnlyList Members { get; } + + /// Gets or sets the member the librarian picked from . + public Member? SelectedMember + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } + + /// Gets the command that marks the book on loan and returns to the catalog. + public ReactiveCommand ConfirmLoan { get; } + + /// Gets the command that returns to the catalog without loaning the book. + public ReactiveCommand Cancel { get; } + + /// + public void Dispose() + { + ConfirmLoan.Dispose(); + Cancel.Dispose(); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/Shared/ViewModels/MembersViewModel.cs b/src/examples/Documentation/Pages/platform-apple/Shared/ViewModels/MembersViewModel.cs new file mode 100644 index 0000000000..ee75b9a882 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/Shared/ViewModels/MembersViewModel.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The members tab: every library member, as the change-set source a reactive table or collection source reads from. +[System.Diagnostics.DebuggerDisplay("MembersViewModel Members = {Members.Count}")] +public sealed class MembersViewModel : ReactiveObject, IRoutableViewModel +{ + /// Initializes a new instance of the class. + /// The shell that owns the router. + /// Every library member. + public MembersViewModel(IScreen hostScreen, IReadOnlyList members) + { + HostScreen = hostScreen; + Members = members as ObservableCollection ?? new ObservableCollection(members); + MemberCount = members.Count; + } + + /// + public string UrlPathSegment => "members"; + + /// + public IScreen HostScreen { get; } + + /// Gets every library member. A reactive table or collection source binds to this directly: because it + /// implements , the source refreshes the + /// view whenever a member is added or removed. + public ObservableCollection Members { get; } + + /// Gets how many members the library has. A plain so a one-way binding can observe + /// it directly, rather than chaining through the non-reactive list. + public int MemberCount { get; } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/AppDelegate.cs b/src/examples/Documentation/Pages/platform-apple/iOS/AppDelegate.cs new file mode 100644 index 0000000000..3307657ea5 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/AppDelegate.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Foundation; +using ReactiveUI.Builder; +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The app's UIApplicationDelegate: builds ReactiveUI, wires up state suspension, and shows the shell. +[Register(nameof(AppDelegate))] +[System.Diagnostics.DebuggerDisplay("AppDelegate")] +public sealed class AppDelegate : UIApplicationDelegate +{ + /// Keeps UIKit lifecycle callbacks translated into suspend/resume signals for as long as the process lives. + private AutoSuspendHelper? _autoSuspendHelper; + + /// + public override UIWindow? Window { get; set; } + + /// + public override bool FinishedLaunching(UIApplication application, NSDictionary? launchOptions) + { + ReactiveUIBuilder builder = RxAppBuilder.CreateReactiveUIBuilder(); + _ = builder.WithPlatformModule().BuildApp(); + + _autoSuspendHelper = new AutoSuspendHelper(this); + _autoSuspendHelper.FinishedLaunching(application, launchOptions!); + Console.WriteLine($"FinishedLaunching captured {_autoSuspendHelper.LaunchOptions?.Count ?? 0} launch option(s)."); + + RxSuspension.SuspensionHost.CreateNewAppState = static () => new LibraryAppState(); + + AppSupportJsonSuspensionDriver driver = new("Library"); + RxSuspension.SuspensionHost.SetupDefaultSuspendResume(driver); + + _ = driver.SaveState(new LibraryAppState { LastViewedBook = "Pride and Prejudice" }, LibraryAppStateJsonContext.Default.LibraryAppState) + .Subscribe(static _ => Console.WriteLine("AppSupportJsonSuspensionDriver saved the app state.")); + _ = driver.LoadState(LibraryAppStateJsonContext.Default.LibraryAppState) + .Subscribe( + static state => Console.WriteLine($"AppSupportJsonSuspensionDriver loaded state for {state?.LastViewedBook}."), + static error => Console.WriteLine($"AppSupportJsonSuspensionDriver had nothing to load yet: {error.Message}.")); + _ = driver.InvalidateState().Subscribe(static _ => Console.WriteLine("AppSupportJsonSuspensionDriver invalidated the saved state.")); + + // GetOrientation() returns the device's current rotation by name, such as "Portrait". + PlatformOperations platformOperations = new(); + string? orientation = platformOperations.GetOrientation(); + Console.WriteLine($"Device orientation: {orientation}."); + + if (OperatingSystem.IsIOSVersionAtLeast(26)) + { + // iOS 26 deprecates window construction outside a UIWindowScene; this sample targets 15 through 25 and + // stays on the classic, scene-less API below that ceiling. + throw new PlatformNotSupportedException("This sample targets iOS 15 through 25."); + } + + UIWindow window = new(UIScreen.MainScreen.Bounds); + window.RootViewController = LibraryComposition.CreateRootViewController(); + window.MakeKeyAndVisible(); + Window = window; + + return true; + } + + /// + public override void OnActivated(UIApplication application) => + _autoSuspendHelper?.OnActivated(application); + + /// + public override void DidEnterBackground(UIApplication application) => + _autoSuspendHelper?.DidEnterBackground(application); + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _autoSuspendHelper?.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/BookCoverPagerViewController.cs b/src/examples/Documentation/Pages/platform-apple/iOS/BookCoverPagerViewController.cs new file mode 100644 index 0000000000..3fcca1c50f --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/BookCoverPagerViewController.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// Pages through one per book, left to right. +[System.Diagnostics.DebuggerDisplay("BookCoverPagerViewController")] +public sealed class BookCoverPagerViewController : ReactivePageViewController, IUIPageViewControllerDataSource +{ + /// Initializes a new instance of the class with a scroll transition. + public BookCoverPagerViewController() + : base(UIPageViewControllerTransitionStyle.Scroll, UIPageViewControllerNavigationOrientation.Horizontal) + { + } + + /// + public override void ViewDidLoad() + { + base.ViewDidLoad(); + + DataSource = this; + + _ = this.WhenActivated((Action onDispose) => + { + _ = onDispose; + if (ViewModel!.Books.Count == 0) + { + return; + } + + SetViewControllers([CreatePage(ViewModel.Books[0])], UIPageViewControllerNavigationDirection.Forward, false, null); + }); + } + + /// + public new UIViewController GetPreviousViewController(UIPageViewController pageViewController, UIViewController referenceViewController) + { + Book current = ((BookDetailViewController)referenceViewController).ViewModel!; + int index = ViewModel!.Books.IndexOf(current) - 1; + return index >= 0 ? CreatePage(ViewModel.Books[index]) : null!; + } + + /// + public new UIViewController GetNextViewController(UIPageViewController pageViewController, UIViewController referenceViewController) + { + Book current = ((BookDetailViewController)referenceViewController).ViewModel!; + int index = ViewModel!.Books.IndexOf(current) + 1; + return index < ViewModel.Books.Count ? CreatePage(ViewModel.Books[index]) : null!; + } + + /// Creates the detail page for one book, with its ViewModel set before the pager displays it. + /// The book the page shows. + /// The page. + private static BookDetailViewController CreatePage(Book book) => new() { ViewModel = book }; +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/BookDetailViewController.cs b/src/examples/Documentation/Pages/platform-apple/iOS/BookDetailViewController.cs new file mode 100644 index 0000000000..3776e79aca --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/BookDetailViewController.cs @@ -0,0 +1,87 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// One book's detail: cover, title, author, and a star rating a librarian can adjust. +[System.Diagnostics.DebuggerDisplay("BookDetailViewController")] +public sealed class BookDetailViewController : ReactiveViewController +{ + /// Hosts the cover and rating controls, added once the view model is set on them first. + private readonly UIStackView _layout; + + /// Initializes a new instance of the class. + public BookDetailViewController() => + _layout = new UIStackView([TitleLabel, AuthorLabel]) + { + Axis = UILayoutConstraintAxis.Vertical, + Alignment = UIStackViewAlignment.Center, + Spacing = 12, + TranslatesAutoresizingMaskIntoConstraints = false, + }; + + /// Gets the label showing the book's title. Internal so the binding source generator can observe it. + internal UILabel TitleLabel { get; } = new() { Font = UIFont.PreferredTitle1! }; + + /// Gets the label showing the book's author. Internal so the binding source generator can observe it. + internal UILabel AuthorLabel { get; } = new() { Font = UIFont.PreferredBody!, TextColor = UIColor.SecondaryLabel }; + + /// + public override void ViewDidLoad() + { + base.ViewDidLoad(); + + View!.BackgroundColor = UIColor.SystemBackground; + View!.AddSubview(_layout); + NSLayoutConstraint.ActivateConstraints( + [ + _layout.CenterXAnchor.ConstraintEqualTo(View!.CenterXAnchor), + _layout.TopAnchor.ConstraintEqualTo(View!.SafeAreaLayoutGuide.TopAnchor, 24), + ]); + + _ = this.WhenActivated(d => + { + Title = ViewModel!.Title; + + // Setting ViewModel before adding each control to the stack means it already has a book to bind to the + // moment it gains a superview and its own activation runs. + BookCoverImageView cover = new() { ViewModel = ViewModel, TranslatesAutoresizingMaskIntoConstraints = false }; + NSLayoutConstraint.ActivateConstraints( + [ + cover.WidthAnchor.ConstraintEqualTo(120), + cover.HeightAnchor.ConstraintEqualTo(160), + ]); + _layout.InsertArrangedSubview(cover, 0); + + StarRatingControl rating = new() { ViewModel = ViewModel }; + _layout.AddArrangedSubview(rating); + + d(this.OneWayBind(ViewModel, static vm => vm.Title, static v => v.TitleLabel.Text)); + d(this.OneWayBind(ViewModel, static vm => vm.Author, static v => v.AuthorLabel.Text)); + d(new ActionDisposable(() => + { + cover.RemoveFromSuperview(); + cover.Dispose(); + rating.RemoveFromSuperview(); + rating.Dispose(); + })); + }); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + TitleLabel.Dispose(); + AuthorLabel.Dispose(); + _layout.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/BookListViewController.cs b/src/examples/Documentation/Pages/platform-apple/iOS/BookListViewController.cs new file mode 100644 index 0000000000..a47e996618 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/BookListViewController.cs @@ -0,0 +1,76 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using CoreGraphics; +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The catalog page: one row per book, and a button that starts a loan for the selected one. +[System.Diagnostics.DebuggerDisplay("BookListViewController")] +public sealed class BookListViewController : ReactiveViewController +{ + /// The stack that hosts one per book. A real catalog would use a reactive table source instead. + private readonly UIStackView _rows = new() { Axis = UILayoutConstraintAxis.Vertical, Spacing = 8, TranslatesAutoresizingMaskIntoConstraints = false }; + + /// Gets the button that opens the loan page for the selected book. Internal for the binding source generator. + internal UIButton LoanButton { get; } = UIButton.FromType(UIButtonType.System); + + /// Gets the plain table listing books currently on loan, hosted below the button. + internal LoanBoardView LoanBoard { get; } = new(CGRect.Empty); + + /// + public override void ViewDidLoad() + { + base.ViewDidLoad(); + + Title = "Catalog"; + View!.BackgroundColor = UIColor.SystemBackground; + LoanButton.SetTitle("Loan selected book", UIControlState.Normal); + LoanBoard.ViewModel = ViewModel; + LoanBoard.HeightAnchor.ConstraintEqualTo(140).Active = true; + + UIStackView layout = new([_rows, LoanButton, LoanBoard]) + { + Axis = UILayoutConstraintAxis.Vertical, + Spacing = 16, + TranslatesAutoresizingMaskIntoConstraints = false, + }; + View!.AddSubview(layout); + NSLayoutConstraint.ActivateConstraints( + [ + layout.LeadingAnchor.ConstraintEqualTo(View!.SafeAreaLayoutGuide.LeadingAnchor, 16), + layout.TrailingAnchor.ConstraintEqualTo(View!.SafeAreaLayoutGuide.TrailingAnchor, -16), + layout.TopAnchor.ConstraintEqualTo(View!.SafeAreaLayoutGuide.TopAnchor, 16), + ]); + + _ = this.WhenActivated(d => + { + foreach (Book book in ViewModel!.Books) + { + // The frame constructor pre-sizes the row; Auto Layout resizes it once ViewModel bindings run. + BookRowView row = new(CGRect.Empty) { ViewModel = book }; + UITapGestureRecognizer tap = new(() => ViewModel!.SelectedBook = book); + row.AddGestureRecognizer(tap); + _rows.AddArrangedSubview(row); + } + + d(this.BindCommand(ViewModel, static vm => vm.OpenLoan, static v => v.LoanButton)); + }); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _rows.Dispose(); + LoanButton.Dispose(); + LoanBoard.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/BookShelfCollectionViewSource.cs b/src/examples/Documentation/Pages/platform-apple/iOS/BookShelfCollectionViewSource.cs new file mode 100644 index 0000000000..491cdab636 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/BookShelfCollectionViewSource.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Foundation; +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// A that also supplies the shelf's section header. The +/// ReactiveCollectionViewSourceExtensions.BindTo family always builds a plain , +/// which has no header hook of its own, so a grid that needs one subclasses the source directly instead, the same +/// way that extension method does internally. +internal sealed class BookShelfCollectionViewSource : ReactiveCollectionViewSource +{ + /// The reuse identifier the shelf registers under. + internal static readonly NSString HeaderKey = new(nameof(ShelfHeaderView)); + + /// The catalog the header's book count comes from. + private readonly BookCatalogViewModel _catalog; + + /// Initializes a new instance of the class. + /// The grid this source drives. + /// The catalog the header's book count comes from. + internal BookShelfCollectionViewSource(UICollectionView collectionView, BookCatalogViewModel catalog) + : base(collectionView) => + _catalog = catalog; + + /// + public override UICollectionReusableView GetViewForSupplementaryElement(UICollectionView collectionView, NSString elementKind, NSIndexPath indexPath) + { + ShelfHeaderView header = (ShelfHeaderView)collectionView.DequeueReusableSupplementaryView(UICollectionElementKindSection.Header, HeaderKey, indexPath); + header.ViewModel = _catalog; + return header; + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/BookShelfViewController.cs b/src/examples/Documentation/Pages/platform-apple/iOS/BookShelfViewController.cs new file mode 100644 index 0000000000..36e24744c6 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/BookShelfViewController.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using CoreGraphics; +using Foundation; +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The shelf tab: every book as a cover tile in a grid. +[System.Diagnostics.DebuggerDisplay("BookShelfViewController")] +public sealed class BookShelfViewController : ReactiveCollectionViewController +{ + /// The reuse identifier every cover shares; the shelf never needs a different cell per book. + private static readonly NSString CoverCellKey = new(nameof(BookCoverCell)); + + /// Initializes a new instance of the class with a two-column grid layout. + public BookShelfViewController() + : base(new UICollectionViewFlowLayout + { + ItemSize = new CGSize(140, 90), + MinimumInteritemSpacing = 12, + MinimumLineSpacing = 12, + SectionInset = new UIEdgeInsets(12, 12, 12, 12), + HeaderReferenceSize = new CGSize(0, 32), + }) + { + } + + /// + public override void ViewDidLoad() + { + base.ViewDidLoad(); + + Title = "Shelf"; + CollectionView!.BackgroundColor = UIColor.SystemBackground; + CollectionView.RegisterClassForCell(typeof(BookCoverCell), CoverCellKey); + CollectionView.RegisterClassForSupplementaryView(typeof(ShelfHeaderView), UICollectionElementKindSection.Header, BookShelfCollectionViewSource.HeaderKey); + + _ = this.WhenActivated(d => + { + CollectionViewSectionInformation section = new( + ViewModel!.Books, + static _ => CoverCellKey, + static cell => Console.WriteLine($"Initializing a {cell.GetType().Name}.")); + IReadOnlyList> sections = [section]; + + BookShelfCollectionViewSource source = new(CollectionView!, ViewModel); + source.Data = sections; + CollectionView!.Source = source; + + CollectionViewSectionInformation readBack = source.Data[0]; + Console.WriteLine( + $"Shelf has {source.Data.Count} section(s); every cover shares one reuse key: {readBack.CellKeySelector is not null}."); + + d(source); + d(source.ElementSelected.Subscribe(static item => Console.WriteLine($"Selected cover {((Book)item!).Title}."))); + }); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/LibraryComposition.cs b/src/examples/Documentation/Pages/platform-apple/iOS/LibraryComposition.cs new file mode 100644 index 0000000000..7f3422e1c3 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/LibraryComposition.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// Builds the sample library data and the app's root view controller. +internal static class LibraryComposition +{ + /// Creates the root view controller: a split view on iPad, a tab bar everywhere else. + /// The root view controller for . + internal static UIViewController CreateRootViewController() + { + Member ada = new("M-1", "Ada"); + Member grace = new("M-2", "Grace"); + ObservableCollection members = [ada, grace]; + + ObservableCollection books = + [ + new("Pride and Prejudice", "Jane Austen"), + new("The Hobbit", "J. R. R. Tolkien"), + new("Dune", "Frank Herbert"), + ]; + + LibraryShellViewModel shell = new(); + BookCatalogViewModel catalog = new(shell, books, members); + MembersViewModel membersViewModel = new(shell, members); + + if (UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Pad) + { + return new LibrarySplitViewController(shell, catalog); + } + + DefaultViewLocator catalogViewLocator = new(); + catalogViewLocator.Map(); + catalogViewLocator.Map(); + + return new LibraryTabBarController(shell, catalogViewLocator, catalog, membersViewModel); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/LibrarySplitViewController.cs b/src/examples/Documentation/Pages/platform-apple/iOS/LibrarySplitViewController.cs new file mode 100644 index 0000000000..661be376b4 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/LibrarySplitViewController.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The app's root on iPad: the catalog as the master column, a as the detail. +[System.Diagnostics.DebuggerDisplay("LibrarySplitViewController")] +public sealed class LibrarySplitViewController : ReactiveSplitViewController +{ + /// Shows the detail view for whichever book the master column selects. + private readonly ViewModelViewHost _detailHost; + + /// Initializes a new instance of the class. + /// The shell view model. + /// The catalog shown in the master column. + public LibrarySplitViewController(LibraryShellViewModel shell, BookCatalogViewModel catalog) + { + ViewModel = shell; + PreferredDisplayMode = UISplitViewControllerDisplayMode.OneBesideSecondary; + + DefaultViewLocator detailLocator = new(); + detailLocator.Map(); + _detailHost = new ViewModelViewHost + { + DefaultContent = new UIViewController(), + ViewLocator = detailLocator, + }; + + BookListViewController master = new() { ViewModel = catalog }; + ViewControllers = [master, _detailHost]; + + _ = this.WhenActivated(d => + d(catalog.WhenAnyValue(static vm => vm.SelectedBook) + .WhereNotNull() + .Subscribe(book => _detailHost.ViewModel = book))); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _detailHost.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/LibraryTabBarController.cs b/src/examples/Documentation/Pages/platform-apple/iOS/LibraryTabBarController.cs new file mode 100644 index 0000000000..7e4dce285d --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/LibraryTabBarController.cs @@ -0,0 +1,57 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The app's root on iPhone: a catalog tab, a members tab, and a cover-paging tab. +[System.Diagnostics.DebuggerDisplay("LibraryTabBarController")] +public sealed class LibraryTabBarController : ReactiveTabBarController +{ + /// Initializes a new instance of the class. + /// The shell that owns the catalog's router. + /// The view locator the catalog tab's uses to + /// resolve a view for each pushed view model. + /// The catalog view model shown first in the catalog tab. + /// The members view model shown in the members tab. + public LibraryTabBarController(LibraryShellViewModel shell, IViewLocator catalogViewLocator, BookCatalogViewModel catalog, MembersViewModel members) + { + ViewModel = shell; + + RoutedViewHost catalogHost = new() + { + Router = shell.Router, + ViewLocator = catalogViewLocator, + }; + catalogHost.TabBarItem = new UITabBarItem("Catalog", null, 0); + _ = shell.Router.Navigate.Execute(catalog).Subscribe(); + + MembersNavigationController membersNav = new(members) + { + TabBarItem = new UITabBarItem("Members", null, 1), + }; + + BookCoverPagerViewController coverPager = new() + { + ViewModel = catalog, + TabBarItem = new UITabBarItem("Covers", null, 2), + }; + + BookShelfViewController shelf = new() + { + ViewModel = catalog, + TabBarItem = new UITabBarItem("Shelf", null, 3), + }; + + ViewControllers = [catalogHost, membersNav, coverPager, shelf]; + + _ = this.WhenActivated(d => + { + d(Activated.Subscribe(static _ => Console.WriteLine("LibraryTabBarController activated."))); + d(Deactivated.Subscribe(static _ => Console.WriteLine("LibraryTabBarController deactivated."))); + }); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/LoanViewController.cs b/src/examples/Documentation/Pages/platform-apple/iOS/LoanViewController.cs new file mode 100644 index 0000000000..df43547ff7 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/LoanViewController.cs @@ -0,0 +1,89 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The loan page: pick a member, then confirm or cancel. Pushed by . +[System.Diagnostics.DebuggerDisplay("LoanViewController")] +public sealed class LoanViewController : ReactiveViewController +{ + /// Names the book being loaned. + private readonly UILabel _bookLabel = new() { Font = UIFont.PreferredHeadline! }; + + /// Hosts one button per member. + private readonly UIStackView _memberButtons = new() { Axis = UILayoutConstraintAxis.Vertical, Spacing = 8, TranslatesAutoresizingMaskIntoConstraints = false }; + + /// Gets the label naming the picked member. Internal for the binding source generator. + internal UILabel SelectedMemberLabel { get; } = new() { TextColor = UIColor.SecondaryLabel }; + + /// Gets the button that confirms the loan. Internal so the binding source generator can observe it. + internal UIButton ConfirmButton { get; } = UIButton.FromType(UIButtonType.System); + + /// Gets the button that cancels the loan. Internal so the binding source generator can observe it. + internal UIButton CancelButton { get; } = UIButton.FromType(UIButtonType.System); + + /// + public override void ViewDidLoad() + { + base.ViewDidLoad(); + + Title = "Loan"; + View!.BackgroundColor = UIColor.SystemBackground; + ConfirmButton.SetTitle("Confirm loan", UIControlState.Normal); + CancelButton.SetTitle("Cancel", UIControlState.Normal); + + UIStackView layout = new([_bookLabel, _memberButtons, SelectedMemberLabel, ConfirmButton, CancelButton]) + { + Axis = UILayoutConstraintAxis.Vertical, + Spacing = 12, + TranslatesAutoresizingMaskIntoConstraints = false, + }; + View!.AddSubview(layout); + NSLayoutConstraint.ActivateConstraints( + [ + layout.LeadingAnchor.ConstraintEqualTo(View!.SafeAreaLayoutGuide.LeadingAnchor, 16), + layout.TrailingAnchor.ConstraintEqualTo(View!.SafeAreaLayoutGuide.TrailingAnchor, -16), + layout.TopAnchor.ConstraintEqualTo(View!.SafeAreaLayoutGuide.TopAnchor, 16), + ]); + + _ = this.WhenActivated(d => + { + _bookLabel.Text = $"Loaning: {ViewModel!.Book.Title}"; + + foreach (Member member in ViewModel.Members) + { + UIButton button = UIButton.FromType(UIButtonType.System); + button.SetTitle(member.Name, UIControlState.Normal); + button.TouchUpInside += (_, _) => ViewModel.SelectedMember = member; + _memberButtons.AddArrangedSubview(button); + } + + d(this.OneWayBind( + ViewModel, + static vm => vm.SelectedMember, + static v => v.SelectedMemberLabel.Text, + static member => member is null ? "(no member selected)" : $"To: {member.Name}")); + d(this.BindCommand(ViewModel, static vm => vm.ConfirmLoan, static v => v.ConfirmButton)); + d(this.BindCommand(ViewModel, static vm => vm.Cancel, static v => v.CancelButton)); + }); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _bookLabel.Dispose(); + _memberButtons.Dispose(); + SelectedMemberLabel.Dispose(); + ConfirmButton.Dispose(); + CancelButton.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/MembersNavigationController.cs b/src/examples/Documentation/Pages/platform-apple/iOS/MembersNavigationController.cs new file mode 100644 index 0000000000..1dde721edd --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/MembersNavigationController.cs @@ -0,0 +1,17 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Documentation.PlatformApple; + +/// Wraps in a navigation bar, its own ViewModel set once. +[System.Diagnostics.DebuggerDisplay("MembersNavigationController")] +public sealed class MembersNavigationController : ReactiveNavigationController +{ + /// Initializes a new instance of the class. + /// The members data the root page shows. + public MembersNavigationController(MembersViewModel viewModel) + : base(new MembersPlaceholderViewController { ViewModel = viewModel }) => + ViewModel = viewModel; +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/MembersPlaceholderViewController.cs b/src/examples/Documentation/Pages/platform-apple/iOS/MembersPlaceholderViewController.cs new file mode 100644 index 0000000000..5d34a441c5 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/MembersPlaceholderViewController.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using CoreGraphics; +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The members tab: a chip strip of every member above a table of the same members, one row each. +[System.Diagnostics.DebuggerDisplay("MembersPlaceholderViewController")] +public sealed class MembersPlaceholderViewController : ReactiveTableViewController +{ + /// Gets the horizontal chip strip above the table. Internal so the binding source generator can reach it. + internal MemberChipStripView ChipStrip { get; } = new( + new CGRect(0, 0, 320, 56), + new UICollectionViewFlowLayout + { + ScrollDirection = UICollectionViewScrollDirection.Horizontal, + ItemSize = new CGSize(48, 48), + MinimumInteritemSpacing = 12, + SectionInset = new UIEdgeInsets(8, 16, 8, 16), + }); + + /// Gets the reactive table source bound in . + internal ReactiveTableViewSource? Source { get; private set; } + + /// + public override void ViewDidLoad() + { + base.ViewDidLoad(); + + Title = "Members"; + ChipStrip.ViewModel = ViewModel; + TableView.RegisterClassForCellReuse(typeof(MemberCell), MemberCell.Key); + TableView.TableHeaderView = ChipStrip; + + _ = this.WhenActivated(d => + { + TableSectionInformation section = new( + ViewModel!.Members, + MemberCell.Key, + sizeHint: 56F, + static cell => Console.WriteLine($"Initializing a {cell.GetType().Name}.")) + { + Header = new TableSectionHeader("Members"), + Footer = new TableSectionHeader( + static () => new UILabel + { + Text = "Tap a member to see their card.", + TextAlignment = UITextAlignment.Center, + Font = UIFont.PreferredFootnote!, + TextColor = UIColor.SecondaryLabel, + }, + 24F), + }; + IReadOnlyList> sections = [section]; + + d(Signal.Emit(sections).BindTo(TableView, source => + { + Source = source; + return source.ElementSelected.Subscribe(static item => Console.WriteLine($"Selected member {((Member)item!).Name}.")); + })); + + TableSectionInformation readBack = Source!.Data[0]; + Console.WriteLine( + $"{readBack.Header?.Title}: {Source.Data.Count} section(s), row height {readBack.SizeHint}, " + + $"has a collection {readBack.Collection is not null}, has an init action {readBack.InitializeCellAction is not null}."); + }); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + ChipStrip.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/Program.cs b/src/examples/Documentation/Pages/platform-apple/iOS/Program.cs new file mode 100644 index 0000000000..aceaa41adc --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/Program.cs @@ -0,0 +1,17 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The iOS entry point. +internal static class Program +{ + /// Hands control to UIKit, which creates and calls its lifecycle methods. + /// The process arguments UIKit forwards to UIApplicationMain. + private static void Main(string[] args) => + UIApplication.Main(args, null, typeof(AppDelegate)); +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/Views/BookCoverCell.cs b/src/examples/Documentation/Pages/platform-apple/iOS/Views/BookCoverCell.cs new file mode 100644 index 0000000000..3705fffdbc --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/Views/BookCoverCell.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// One cover tile in the book-cover grid: title and loan status. +[System.Diagnostics.DebuggerDisplay("BookCoverCell")] +public sealed class BookCoverCell : ReactiveCollectionViewCell +{ + /// Initializes a new instance of the class from a native handle. + /// The native handle UIKit hands the base constructor. + public BookCoverCell(IntPtr handle) + : base(handle) + { + ContentView.BackgroundColor = UIColor.SecondarySystemBackground; + + UIStackView stack = new([TitleLabel, StatusLabel]) + { + Axis = UILayoutConstraintAxis.Vertical, + Spacing = 2, + TranslatesAutoresizingMaskIntoConstraints = false, + }; + ContentView.AddSubview(stack); + NSLayoutConstraint.ActivateConstraints( + [ + stack.LeadingAnchor.ConstraintEqualTo(ContentView.LeadingAnchor, 8), + stack.TrailingAnchor.ConstraintEqualTo(ContentView.TrailingAnchor, -8), + stack.CenterYAnchor.ConstraintEqualTo(ContentView.CenterYAnchor), + ]); + + _ = this.WhenActivated(d => + { + d(this.OneWayBind(ViewModel, static vm => vm.Title, static v => v.TitleLabel.Text)); + d(this.OneWayBind( + ViewModel, + static vm => vm.IsOnLoan, + static v => v.StatusLabel.Text, + static onLoan => onLoan ? "On loan" : "Available")); + }); + } + + /// Gets the book's title. Internal so the binding source generator can observe it. + internal UILabel TitleLabel { get; } = new() { Font = UIFont.PreferredCaption1!, Lines = 2 }; + + /// Gets the book's loan status. Internal so the binding source generator can observe it. + internal UILabel StatusLabel { get; } = new() { Font = UIFont.PreferredCaption2!, TextColor = UIColor.SecondaryLabel }; + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + TitleLabel.Dispose(); + StatusLabel.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/Views/BookCoverImageView.cs b/src/examples/Documentation/Pages/platform-apple/iOS/Views/BookCoverImageView.cs new file mode 100644 index 0000000000..b38cbc61df --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/Views/BookCoverImageView.cs @@ -0,0 +1,26 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// A book's cover. This app has no bundled artwork, so a blank image stands in and the accessibility label +/// carries the book's title instead. +[System.Diagnostics.DebuggerDisplay("BookCoverImageView")] +public sealed class BookCoverImageView : ReactiveImageView +{ + /// Initializes a new instance of the class. + public BookCoverImageView() + { + Image ??= new UIImage(); + + _ = this.WhenActivated(d => + { + d(this.OneWayBind(ViewModel, static vm => vm.Title, static v => v.AccessibilityLabel)); + d(ThrownExceptions.Subscribe(static error => Console.WriteLine($"BookCoverImageView threw: {error.Message}"))); + }); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/Views/BookRowView.cs b/src/examples/Documentation/Pages/platform-apple/iOS/Views/BookRowView.cs new file mode 100644 index 0000000000..36824388ad --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/Views/BookRowView.cs @@ -0,0 +1,72 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using CoreGraphics; +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// One row in the catalog: title, author, and loan status. A real catalog would use a reactive table source instead. +[System.Diagnostics.DebuggerDisplay("BookRowView")] +public sealed class BookRowView : ReactiveView +{ + /// Initializes a new instance of the class. + public BookRowView() => Initialize(); + + /// Initializes a new instance of the class with an explicit frame. + /// The row's initial frame. + public BookRowView(CGRect frame) + : base(frame) => + Initialize(); + + /// Gets the book's title. Internal so the binding source generator can observe it. + internal UILabel TitleLabel { get; } = new() { Font = UIFont.PreferredHeadline! }; + + /// Gets the book's loan status. Internal so the binding source generator can observe it. + internal UILabel StatusLabel { get; } = new() { Font = UIFont.PreferredSubheadline!, TextColor = UIColor.SecondaryLabel }; + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + TitleLabel.Dispose(); + StatusLabel.Dispose(); + } + + base.Dispose(disposing); + } + + /// Lays out the labels and binds them to the book set as the view model before this row is added to a superview. + private void Initialize() + { + UIStackView stack = new([TitleLabel, StatusLabel]) + { + Axis = UILayoutConstraintAxis.Vertical, + Spacing = 2, + TranslatesAutoresizingMaskIntoConstraints = false, + }; + AddSubview(stack); + NSLayoutConstraint.ActivateConstraints( + [ + stack.LeadingAnchor.ConstraintEqualTo(LeadingAnchor), + stack.TrailingAnchor.ConstraintEqualTo(TrailingAnchor), + stack.TopAnchor.ConstraintEqualTo(TopAnchor), + stack.BottomAnchor.ConstraintEqualTo(BottomAnchor), + ]); + + _ = this.WhenActivated(d => + { + d(this.OneWayBind(ViewModel, static vm => vm.Title, static v => v.TitleLabel.Text)); + d(this.OneWayBind( + ViewModel, + static vm => vm.IsOnLoan, + static v => v.StatusLabel.Text, + static onLoan => onLoan ? "On loan" : "Available")); + d(Activated.Subscribe(static _ => Console.WriteLine("BookRowView activated."))); + d(Deactivated.Subscribe(static _ => Console.WriteLine("BookRowView deactivated."))); + }); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/Views/LoanBoardView.cs b/src/examples/Documentation/Pages/platform-apple/iOS/Views/LoanBoardView.cs new file mode 100644 index 0000000000..ba35031727 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/Views/LoanBoardView.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; +using CoreGraphics; +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// A plain hosted directly as a subview, listing books currently on loan. +[System.Diagnostics.DebuggerDisplay("LoanBoardView")] +public sealed class LoanBoardView : ReactiveTableView +{ + /// Initializes a new instance of the class. + /// The board's initial frame. + public LoanBoardView(CGRect frame) + : base(frame, UITableViewStyle.Plain) + { + _ = this.WhenActivated(d => + { + ObservableCollection onLoan = new(ViewModel!.Books.Where(static book => book.IsOnLoan)); + d(Signal.Emit(onLoan).BindTo(this, sizeHint: 44F)); + }); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/Views/LoanedBookCell.cs b/src/examples/Documentation/Pages/platform-apple/iOS/Views/LoanedBookCell.cs new file mode 100644 index 0000000000..c12e9f74de --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/Views/LoanedBookCell.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// One row in the on-loan board: a book's title. +[System.Diagnostics.DebuggerDisplay("LoanedBookCell")] +public sealed class LoanedBookCell : ReactiveTableViewCell +{ + /// Initializes a new instance of the class from a native handle. + /// The native handle UIKit hands the base constructor. + public LoanedBookCell(IntPtr handle) + : base(handle) + { + ContentView.AddSubview(TitleLabel); + TitleLabel.TranslatesAutoresizingMaskIntoConstraints = false; + NSLayoutConstraint.ActivateConstraints( + [ + TitleLabel.LeadingAnchor.ConstraintEqualTo(ContentView.LeadingAnchor, 16), + TitleLabel.CenterYAnchor.ConstraintEqualTo(ContentView.CenterYAnchor), + ]); + + _ = this.WhenActivated(d => + d(this.OneWayBind(ViewModel, static vm => vm.Title, static v => v.TitleLabel.Text))); + } + + /// Gets the book's title. Internal so the binding source generator can observe it. + internal UILabel TitleLabel { get; } = new() { Font = UIFont.PreferredBody! }; + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + TitleLabel.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/Views/MemberCell.cs b/src/examples/Documentation/Pages/platform-apple/iOS/Views/MemberCell.cs new file mode 100644 index 0000000000..b22eaaaae1 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/Views/MemberCell.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Foundation; +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// One row of the members table: a member's name, and the one cell that shows the six classic IReactiveObject members every reactive Apple type carries. +[System.Diagnostics.DebuggerDisplay("MemberCell")] +public sealed class MemberCell : ReactiveTableViewCell +{ + /// The reuse identifier registers this cell under. + internal static readonly NSString Key = new(nameof(MemberCell)); + + /// Initializes a new instance of the class from a native handle. + /// The native handle UIKit hands the base constructor. + public MemberCell(IntPtr handle) + : base(handle) + { + ContentView.AddSubview(NameLabel); + NameLabel.TranslatesAutoresizingMaskIntoConstraints = false; + NSLayoutConstraint.ActivateConstraints( + [ + NameLabel.LeadingAnchor.ConstraintEqualTo(ContentView.LeadingAnchor, 16), + NameLabel.CenterYAnchor.ConstraintEqualTo(ContentView.CenterYAnchor), + ]); + + // The classic events fire for any property change; a cell can use them without going through Changed/Changing. + PropertyChanged += static (_, e) => Console.WriteLine($"MemberCell.{e.PropertyName} changed (classic event)."); + PropertyChanging += static (_, e) => Console.WriteLine($"MemberCell.{e.PropertyName} changing (classic event)."); + + _ = this.WhenActivated(d => + { + // Member is an immutable record, not a ReactiveObject, so the label follows ViewModel itself changing + // (which the cell base class does raise) rather than a OneWayBind on one of its properties. + d(this.WhenAnyValue(static v => v.ViewModel).Subscribe(member => NameLabel.Text = member?.Name)); + d(Changing.Subscribe(static _ => Console.WriteLine("MemberCell changing."))); + d(Changed.Subscribe(static _ => Console.WriteLine("MemberCell changed."))); + d(ThrownExceptions.Subscribe(static error => Console.WriteLine($"MemberCell binding failed: {error.Message}"))); + d(Activated.Subscribe(static _ => Console.WriteLine("MemberCell activated."))); + d(Deactivated.Subscribe(static _ => Console.WriteLine("MemberCell deactivated."))); + }); + } + + /// Gets the member's name. Internal so the binding source generator can observe it. + internal UILabel NameLabel { get; } = new() { Font = UIFont.PreferredBody! }; + + /// + public override void PrepareForReuse() + { + // Clearing the binding target before reuse should not itself look like a change to any observer, so it runs + // inside a suppression scope. Only the real value set once the cell is dequeued again should notify. + using (SuppressChangeNotifications()) + { + ViewModel = null; + } + + base.PrepareForReuse(); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + NameLabel.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/Views/MemberChipCell.cs b/src/examples/Documentation/Pages/platform-apple/iOS/Views/MemberChipCell.cs new file mode 100644 index 0000000000..c42a0129d1 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/Views/MemberChipCell.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using CoreGraphics; +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// One chip in the horizontal member strip: the member's initial in a circle. +[System.Diagnostics.DebuggerDisplay("MemberChipCell")] +public sealed class MemberChipCell : ReactiveCollectionViewCell +{ + /// Initializes a new instance of the class from a native handle. + /// The native handle UIKit hands the base constructor. + public MemberChipCell(IntPtr handle) + : base(handle) + { + ContentView.BackgroundColor = UIColor.SystemGray5; + ContentView.Layer.CornerRadius = 20; + ContentView.AddSubview(InitialLabel); + InitialLabel.Frame = new CGRect(0, 0, 40, 40); + InitialLabel.AutoresizingMask = UIViewAutoresizing.FlexibleDimensions; + + // Member is an immutable record, not a ReactiveObject, so the label follows ViewModel itself changing rather + // than a OneWayBind on one of its properties. + _ = this.WhenActivated(d => + d(this.WhenAnyValue(static v => v.ViewModel) + .Subscribe(member => InitialLabel.Text = member?.Name[..1].ToUpperInvariant()))); + } + + /// Gets the member's initial. Internal so the binding source generator can observe it. + internal UILabel InitialLabel { get; } = new() { TextAlignment = UITextAlignment.Center, Font = UIFont.PreferredHeadline! }; + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + InitialLabel.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/Views/MemberChipStripView.cs b/src/examples/Documentation/Pages/platform-apple/iOS/Views/MemberChipStripView.cs new file mode 100644 index 0000000000..1e87bc26ee --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/Views/MemberChipStripView.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.Specialized; +using CoreGraphics; +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// A plain : no collection view controller owns it, so +/// hosts it directly as the members table's header, listing the same +/// members as a horizontal strip of chips. +[System.Diagnostics.DebuggerDisplay("MemberChipStripView")] +public sealed class MemberChipStripView : ReactiveCollectionView +{ + /// Initializes a new instance of the class. + /// The strip's initial frame. + /// The horizontal flow layout the strip scrolls under. + public MemberChipStripView(CGRect frame, UICollectionViewLayout layout) + : base(frame, layout) + { + BackgroundColor = UIColor.SystemBackground; + + _ = this.WhenActivated(d => + d(Signal.Emit(ViewModel!.Members).BindTo(this))); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/Views/ShelfHeaderView.cs b/src/examples/Documentation/Pages/platform-apple/iOS/Views/ShelfHeaderView.cs new file mode 100644 index 0000000000..c9e948e51b --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/Views/ShelfHeaderView.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The single section header above the book-cover grid, naming how many books it shows. +[System.Diagnostics.DebuggerDisplay("ShelfHeaderView")] +public sealed class ShelfHeaderView : ReactiveCollectionReusableView +{ + /// Initializes a new instance of the class from a native handle. + /// The native handle UIKit hands the base constructor. + public ShelfHeaderView(IntPtr handle) + : base(handle) + { + AddSubview(CountLabel); + CountLabel.TranslatesAutoresizingMaskIntoConstraints = false; + NSLayoutConstraint.ActivateConstraints( + [ + CountLabel.LeadingAnchor.ConstraintEqualTo(LeadingAnchor, 16), + CountLabel.CenterYAnchor.ConstraintEqualTo(CenterYAnchor), + ]); + + // A supplementary view is dequeued and given its ViewModel before UIKit adds it to the view hierarchy, so it + // reacts to the property directly rather than through WhenActivated. + _ = this.WhenAnyValue(static v => v.ViewModel) + .WhereNotNull() + .Subscribe(vm => CountLabel.Text = $"{vm.Books.Count} book(s)"); + } + + /// Gets the label naming how many books the shelf has. Internal so the binding source generator can observe it. + internal UILabel CountLabel { get; } = new() { Font = UIFont.PreferredHeadline! }; + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + CountLabel.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/iOS/Views/StarRatingControl.cs b/src/examples/Documentation/Pages/platform-apple/iOS/Views/StarRatingControl.cs new file mode 100644 index 0000000000..46c56e3423 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/iOS/Views/StarRatingControl.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using UIKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// A star-rating control wrapping a that binds two-way to . +[System.Diagnostics.DebuggerDisplay("StarRatingControl {Stars}")] +public sealed class StarRatingControl : ReactiveControl +{ + /// The stepper this control wraps. UIKit has no built-in star control, so a stepper stands in for one. + private readonly UIStepper _stepper = new() { MinimumValue = 0, MaximumValue = 5, TranslatesAutoresizingMaskIntoConstraints = false }; + + /// Initializes a new instance of the class. + public StarRatingControl() => Initialize(); + + /// Raised whenever changes, so the binding layer's naming convention (a + /// StarsChanged event for a Stars property) can drive a two-way Bind. + public event EventHandler? StarsChanged; + + /// Gets or sets the rating, from 0 to 5. + public int Stars + { + get => (int)_stepper.Value; + set => _stepper.Value = value; + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _stepper.Dispose(); + } + + base.Dispose(disposing); + } + + /// Builds the stepper, wires its native event to , and binds it to . + private void Initialize() + { + AddSubview(_stepper); + NSLayoutConstraint.ActivateConstraints( + [ + _stepper.LeadingAnchor.ConstraintEqualTo(LeadingAnchor), + _stepper.TopAnchor.ConstraintEqualTo(TopAnchor), + _stepper.BottomAnchor.ConstraintEqualTo(BottomAnchor), + ]); + _stepper.ValueChanged += (_, _) => StarsChanged?.Invoke(this, EventArgs.Empty); + + // A rating of 0 never fires a change notification on its own, so start silent rather than log a phantom change. + using (SuppressChangeNotifications()) + { + Stars = 0; + } + + PropertyChanging += static (_, e) => Console.WriteLine($"StarRatingControl.{e.PropertyName} is changing."); + PropertyChanged += (_, e) => Console.WriteLine($"StarRatingControl.{e.PropertyName} changed to {Stars}."); + + _ = this.WhenActivated(d => + { + d(this.Bind(ViewModel, static vm => vm.Rating, static v => v.Stars)); + d(Changed.Subscribe(static change => Console.WriteLine($"StarRatingControl.{change.PropertyName} changed (Changed stream)."))); + d(Changing.Subscribe(static change => Console.WriteLine($"StarRatingControl.{change.PropertyName} is changing (Changing stream)."))); + d(ThrownExceptions.Subscribe(static error => Console.WriteLine($"StarRatingControl threw: {error.Message}"))); + d(Activated.Subscribe(static _ => Console.WriteLine("StarRatingControl activated."))); + d(Deactivated.Subscribe(static _ => Console.WriteLine("StarRatingControl deactivated."))); + }); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/macOS/AppDelegate.cs b/src/examples/Documentation/Pages/platform-apple/macOS/AppDelegate.cs new file mode 100644 index 0000000000..748fa9e877 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/macOS/AppDelegate.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using AppKit; +using Foundation; +using ReactiveUI.Builder; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The app's NSApplicationDelegate: builds ReactiveUI, wires up state suspension, and shows the window. +[Register(nameof(AppDelegate))] +[System.Diagnostics.DebuggerDisplay("AppDelegate")] +public sealed class AppDelegate : NSApplicationDelegate +{ + /// Keeps AppKit lifecycle notifications translated into suspend/resume signals for as long as the process lives. + private AutoSuspendHelper? _autoSuspendHelper; + + /// Keeps the window controller alive for as long as the app runs. + private MainWindowController? _mainWindowController; + + /// + public override void DidFinishLaunching(NSNotification notification) + { + ReactiveUIBuilder builder = RxAppBuilder.CreateReactiveUIBuilder(); + _ = builder.WithPlatformModule().BuildApp(); + + _autoSuspendHelper = new AutoSuspendHelper(this); + _autoSuspendHelper.DidFinishLaunching(notification); + RxSuspension.SuspensionHost.CreateNewAppState = static () => new LibraryAppState(); + + AppSupportJsonSuspensionDriver driver = new(); + RxSuspension.SuspensionHost.SetupDefaultSuspendResume(driver); + + _ = driver.SaveState(new LibraryAppState { LastViewedBook = "Dune" }, LibraryAppStateJsonContext.Default.LibraryAppState) + .Subscribe(static _ => Console.WriteLine("AppSupportJsonSuspensionDriver saved the app state.")); + _ = driver.LoadState(LibraryAppStateJsonContext.Default.LibraryAppState) + .Subscribe( + static state => Console.WriteLine($"AppSupportJsonSuspensionDriver loaded state for {state?.LastViewedBook}."), + static error => Console.WriteLine($"AppSupportJsonSuspensionDriver had nothing to load yet: {error.Message}.")); + _ = driver.InvalidateState().Subscribe(static _ => Console.WriteLine("AppSupportJsonSuspensionDriver invalidated the saved state.")); + + PlatformOperations platformOperations = new(); + string? orientation = platformOperations.GetOrientation(); + Console.WriteLine($"Device orientation reported by AppKit: {orientation ?? "(none; orientation is a UIKit concept)"}."); + + _mainWindowController = new MainWindowController(); + _mainWindowController.ShowWindow(this); + } + + /// + public override void DidBecomeActive(NSNotification notification) => + _autoSuspendHelper?.DidBecomeActive(notification); + + /// + public override void DidResignActive(NSNotification notification) => + _autoSuspendHelper?.DidResignActive(notification); + + /// + public override void DidHide(NSNotification notification) => + _autoSuspendHelper?.DidHide(notification); + + /// + public override NSApplicationTerminateReply ApplicationShouldTerminate(NSApplication sender) => + _autoSuspendHelper?.ApplicationShouldTerminate(sender) ?? NSApplicationTerminateReply.Now; + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _autoSuspendHelper?.Dispose(); + _mainWindowController?.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/macOS/BookDetailViewController.cs b/src/examples/Documentation/Pages/platform-apple/macOS/BookDetailViewController.cs new file mode 100644 index 0000000000..c7d327392e --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/macOS/BookDetailViewController.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using AppKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// One book's detail: cover, title, author, and a star rating. Hosted as the split view's detail pane. +[System.Diagnostics.DebuggerDisplay("BookDetailViewController")] +public sealed class BookDetailViewController : ReactiveViewController +{ + /// Hosts the cover and rating controls, added once the view model is set on them first. + private NSStackView _layout = new(); + + /// Gets the label showing the book's title. Internal so the binding source generator can observe it. + internal NSTextField TitleLabel { get; } = NSTextField.CreateLabel(string.Empty); + + /// Gets the label showing the book's author. Internal so the binding source generator can observe it. + internal NSTextField AuthorLabel { get; } = NSTextField.CreateLabel(string.Empty); + + /// + public override void LoadView() + { + NSView view = new(); + View = view; + + _layout = new NSStackView { Orientation = NSUserInterfaceLayoutOrientation.Vertical, Spacing = 12, TranslatesAutoresizingMaskIntoConstraints = false }; + _layout.AddArrangedSubview(TitleLabel); + _layout.AddArrangedSubview(AuthorLabel); + view.AddSubview(_layout); + NSLayoutConstraint.ActivateConstraints( + [ + _layout.CenterXAnchor.ConstraintEqualTo(view.CenterXAnchor), + _layout.TopAnchor.ConstraintEqualTo(view.TopAnchor, 24), + ]); + + _ = this.WhenActivated(d => + { + // Setting ViewModel before adding each control to the stack means it already has a book to bind to the + // moment it gains a superview and its own activation runs. + BookCoverImageView cover = new() { ViewModel = ViewModel, TranslatesAutoresizingMaskIntoConstraints = false }; + NSLayoutConstraint.ActivateConstraints( + [ + cover.WidthAnchor.ConstraintEqualTo(120), + cover.HeightAnchor.ConstraintEqualTo(160), + ]); + _layout.InsertArrangedSubview(cover, 0); + + StarRatingControl rating = new() { ViewModel = ViewModel }; + _layout.AddArrangedSubview(rating); + + d(this.OneWayBind(ViewModel, static vm => vm.Title, static v => v.TitleLabel.StringValue)); + d(this.OneWayBind(ViewModel, static vm => vm.Author, static v => v.AuthorLabel.StringValue)); + d(new ActionDisposable(() => + { + cover.RemoveFromSuperview(); + cover.Dispose(); + rating.RemoveFromSuperview(); + rating.Dispose(); + })); + }); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + TitleLabel.Dispose(); + AuthorLabel.Dispose(); + _layout.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/macOS/BookListViewController.cs b/src/examples/Documentation/Pages/platform-apple/macOS/BookListViewController.cs new file mode 100644 index 0000000000..b67be5cb0e --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/macOS/BookListViewController.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using AppKit; +using CoreGraphics; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The catalog page: one row per book with a "Select" button, and a button that loans the selected book. +[System.Diagnostics.DebuggerDisplay("BookListViewController")] +public sealed class BookListViewController : ReactiveViewController +{ + /// The stack that hosts one row per book. A real catalog would use a reactive table source instead. + private readonly NSStackView _rows = new() { Orientation = NSUserInterfaceLayoutOrientation.Vertical, Spacing = 8, TranslatesAutoresizingMaskIntoConstraints = false }; + + /// Gets the button that opens the loan page for the selected book. Internal for the binding source generator. + internal NSButton LoanButton { get; } = new() { Title = "Loan selected book", BezelStyle = NSBezelStyle.Rounded }; + + /// + public override void LoadView() + { + NSView view = new(); + View = view; + + NSStackView layout = new() { Orientation = NSUserInterfaceLayoutOrientation.Vertical, Spacing = 16, TranslatesAutoresizingMaskIntoConstraints = false }; + layout.AddArrangedSubview(_rows); + layout.AddArrangedSubview(LoanButton); + view.AddSubview(layout); + NSLayoutConstraint.ActivateConstraints( + [ + layout.LeadingAnchor.ConstraintEqualTo(view.LeadingAnchor, 16), + layout.TopAnchor.ConstraintEqualTo(view.TopAnchor, 16), + ]); + + _ = this.WhenActivated(d => + { + foreach (Book book in ViewModel!.Books) + { + // The frame constructor pre-sizes the row; Auto Layout resizes it once ViewModel bindings run. + BookRowView row = new(CGRect.Empty) { ViewModel = book }; + NSButton selectButton = new() { Title = $"Select {book.Title}", BezelStyle = NSBezelStyle.Rounded }; + selectButton.Activated += (_, _) => ViewModel!.SelectedBook = book; + + NSStackView rowLayout = new() { Orientation = NSUserInterfaceLayoutOrientation.Horizontal, Spacing = 8 }; + rowLayout.AddArrangedSubview(row); + rowLayout.AddArrangedSubview(selectButton); + _rows.AddArrangedSubview(rowLayout); + } + + d(this.BindCommand(ViewModel, static vm => vm.OpenLoan, static v => v.LoanButton)); + }); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _rows.Dispose(); + LoanButton.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/macOS/LibrarySplitViewController.cs b/src/examples/Documentation/Pages/platform-apple/macOS/LibrarySplitViewController.cs new file mode 100644 index 0000000000..ce4f964c3b --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/macOS/LibrarySplitViewController.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using AppKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The window's content: the catalog as one split item, a as the other. +[System.Diagnostics.DebuggerDisplay("LibrarySplitViewController")] +public sealed class LibrarySplitViewController : ReactiveSplitViewController +{ + /// Shows the detail view for whichever book the catalog selects. + private readonly ViewModelViewHost _detailHost; + + /// Initializes a new instance of the class. + /// The shell view model. + /// The catalog shown in the first split item. + public LibrarySplitViewController(LibraryShellViewModel shell, BookCatalogViewModel catalog) + { + ViewModel = shell; + + DefaultViewLocator detailLocator = new(); + detailLocator.Map(); + _detailHost = new ViewModelViewHost + { + DefaultContent = new NSViewController(), + ViewLocator = detailLocator, + }; + + BookListViewController master = new() { ViewModel = catalog }; + AddSplitViewItem(NSSplitViewItem.FromViewController(master)); + AddSplitViewItem(NSSplitViewItem.FromViewController(_detailHost)); + + _ = this.WhenActivated(d => + d(catalog.WhenAnyValue(static vm => vm.SelectedBook) + .WhereNotNull() + .Subscribe(book => _detailHost.ViewModel = book))); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _detailHost.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/macOS/MainWindowController.cs b/src/examples/Documentation/Pages/platform-apple/macOS/MainWindowController.cs new file mode 100644 index 0000000000..14cf421cd6 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/macOS/MainWindowController.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using AppKit; +using CoreGraphics; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// +/// The app's single window, hosting the library split view. It derives from +/// , the form of the non-generic +/// , so it has a ViewModel and can use WhenActivated. +/// +[System.Diagnostics.DebuggerDisplay("MainWindowController")] +public sealed class MainWindowController : ReactiveWindowController +{ + /// Initializes a new instance of the class with the library's sample data. + public MainWindowController() + : base(CreateWindow()) + { + } + + /// + public override void WindowDidLoad() + { + base.WindowDidLoad(); + + Member ada = new("M-1", "Ada"); + Member grace = new("M-2", "Grace"); + List members = [ada, grace]; + List books = + [ + new("Pride and Prejudice", "Jane Austen"), + new("The Hobbit", "J. R. R. Tolkien"), + new("Dune", "Frank Herbert"), + ]; + + LibraryShellViewModel shell = new(); + BookCatalogViewModel catalog = new(shell, books, members); + ViewModel = shell; + + Window!.ContentViewController = new LibrarySplitViewController(shell, catalog); + + // ViewModel is set above, so the binding below has a shell to read Title from the moment activation runs. + _ = this.WhenActivated(d => + d(this.OneWayBind(ViewModel, static vm => vm.Title, static v => v.Window!.Title))); + } + + /// Creates the window this controller manages. AppKit needs the window before runs. + /// A borderless-menu, resizable window sized for the catalog. + private static NSWindow CreateWindow() => + new( + new CGRect(0, 0, 640, 480), + NSWindowStyle.Titled | NSWindowStyle.Closable | NSWindowStyle.Miniaturizable | NSWindowStyle.Resizable, + NSBackingStore.Buffered, + false) + { + Title = "Library", + }; +} diff --git a/src/examples/Documentation/Pages/platform-apple/macOS/MembersPlaceholderViewController.cs b/src/examples/Documentation/Pages/platform-apple/macOS/MembersPlaceholderViewController.cs new file mode 100644 index 0000000000..2400a0507e --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/macOS/MembersPlaceholderViewController.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using AppKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The members placeholder: counts members. Listing each in a reactive table is a different chunk of the surface. +[System.Diagnostics.DebuggerDisplay("MembersPlaceholderViewController")] +public sealed class MembersPlaceholderViewController : ReactiveViewController +{ + /// Gets the label naming how many members the library has. Internal for the binding source generator. + internal NSTextField CountLabel { get; } = NSTextField.CreateLabel(string.Empty); + + /// + public override void LoadView() + { + NSView view = new(); + View = view; + view.AddSubview(CountLabel); + CountLabel.TranslatesAutoresizingMaskIntoConstraints = false; + NSLayoutConstraint.ActivateConstraints( + [ + CountLabel.CenterXAnchor.ConstraintEqualTo(view.CenterXAnchor), + CountLabel.TopAnchor.ConstraintEqualTo(view.TopAnchor, 24), + ]); + + _ = this.WhenActivated(d => + d(this.OneWayBind(ViewModel, static vm => vm.MemberCount, static v => v.CountLabel.StringValue, static count => $"{count} member(s)"))); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + CountLabel.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/macOS/Program.cs b/src/examples/Documentation/Pages/platform-apple/macOS/Program.cs new file mode 100644 index 0000000000..e9dba423d8 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/macOS/Program.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using AppKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// The macOS entry point. +internal static class Program +{ + /// + /// Starts AppKit directly instead of calling NSApplication.Main, because this app builds its window and + /// menu in code and has no storyboard to wire up as NSApplication.Delegate. + /// + /// The process arguments. Unused: this app takes no command-line configuration. + private static void Main(string[] args) + { + _ = args; + NSApplication.Init(); + NSApplication.SharedApplication.Delegate = new AppDelegate(); + NSApplication.Main([]); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/macOS/Views/BookCoverImageView.cs b/src/examples/Documentation/Pages/platform-apple/macOS/Views/BookCoverImageView.cs new file mode 100644 index 0000000000..ce8ec972e5 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/macOS/Views/BookCoverImageView.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using AppKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// A book's cover. This app has no bundled artwork, so a blank image stands in and the tooltip carries the book's title. +[System.Diagnostics.DebuggerDisplay("BookCoverImageView")] +public sealed class BookCoverImageView : ReactiveImageView +{ + /// Initializes a new instance of the class. + public BookCoverImageView() + { + Image ??= new NSImage(); + + _ = this.WhenActivated(d => + { + d(this.OneWayBind(ViewModel, static vm => vm.Title, static v => v.ToolTip)); + d(ThrownExceptions.Subscribe(static error => Console.WriteLine($"BookCoverImageView threw: {error.Message}"))); + }); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/macOS/Views/BookRowView.cs b/src/examples/Documentation/Pages/platform-apple/macOS/Views/BookRowView.cs new file mode 100644 index 0000000000..c374b31be8 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/macOS/Views/BookRowView.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using AppKit; +using CoreGraphics; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// One row in the catalog: title, author, and loan status. A real catalog would use a reactive table source instead. +[System.Diagnostics.DebuggerDisplay("BookRowView")] +public sealed class BookRowView : ReactiveView +{ + /// Initializes a new instance of the class. + public BookRowView() => Initialize(); + + /// Initializes a new instance of the class with an explicit frame. + /// The row's initial frame. + public BookRowView(CGRect frame) + : base(frame) => + Initialize(); + + /// Gets the book's title label. Internal so the binding source generator can observe it. + internal NSTextField TitleLabel { get; } = NSTextField.CreateLabel(string.Empty); + + /// Gets the book's loan status label. Internal so the binding source generator can observe it. + internal NSTextField StatusLabel { get; } = NSTextField.CreateLabel(string.Empty); + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + TitleLabel.Dispose(); + StatusLabel.Dispose(); + } + + base.Dispose(disposing); + } + + /// Lays out the labels and binds them to the book set as the view model before this row is added to a superview. + private void Initialize() + { + NSStackView stack = new() + { + Orientation = NSUserInterfaceLayoutOrientation.Vertical, + Spacing = 2, + TranslatesAutoresizingMaskIntoConstraints = false, + }; + stack.AddArrangedSubview(TitleLabel); + stack.AddArrangedSubview(StatusLabel); + AddSubview(stack); + NSLayoutConstraint.ActivateConstraints( + [ + stack.LeadingAnchor.ConstraintEqualTo(LeadingAnchor), + stack.TrailingAnchor.ConstraintEqualTo(TrailingAnchor), + stack.TopAnchor.ConstraintEqualTo(TopAnchor), + stack.BottomAnchor.ConstraintEqualTo(BottomAnchor), + ]); + + _ = this.WhenActivated(d => + { + d(this.OneWayBind(ViewModel, static vm => vm.Title, static v => v.TitleLabel.StringValue)); + d(this.OneWayBind( + ViewModel, + static vm => vm.IsOnLoan, + static v => v.StatusLabel.StringValue, + static onLoan => onLoan ? "On loan" : "Available")); + d(Activated.Subscribe(static _ => Console.WriteLine("BookRowView activated."))); + d(Deactivated.Subscribe(static _ => Console.WriteLine("BookRowView deactivated."))); + }); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/macOS/Views/StarRatingControl.cs b/src/examples/Documentation/Pages/platform-apple/macOS/Views/StarRatingControl.cs new file mode 100644 index 0000000000..c79c95d633 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/macOS/Views/StarRatingControl.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using AppKit; + +namespace ReactiveUI.Documentation.PlatformApple; + +/// A star-rating control wrapping an that binds two-way to . +[System.Diagnostics.DebuggerDisplay("StarRatingControl {Stars}")] +public sealed class StarRatingControl : ReactiveControl +{ + /// The stepper this control wraps. AppKit has no built-in star control, so a stepper stands in for one. + private readonly NSStepper _stepper = new() { MinValue = 0, MaxValue = 5, TranslatesAutoresizingMaskIntoConstraints = false }; + + /// Initializes a new instance of the class. + public StarRatingControl() => Initialize(); + + /// Raised whenever changes, so the binding layer's naming convention (a + /// StarsChanged event for a Stars property) can drive a two-way Bind. + public event EventHandler? StarsChanged; + + /// Gets or sets the rating, from 0 to 5. + public int Stars + { + get => (int)_stepper.DoubleValue; + set => _stepper.DoubleValue = value; + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _stepper.Dispose(); + } + + base.Dispose(disposing); + } + + /// Builds the stepper, wires its action to , and binds it to . + private void Initialize() + { + AddSubview(_stepper); + NSLayoutConstraint.ActivateConstraints( + [ + _stepper.LeadingAnchor.ConstraintEqualTo(LeadingAnchor), + _stepper.TopAnchor.ConstraintEqualTo(TopAnchor), + _stepper.BottomAnchor.ConstraintEqualTo(BottomAnchor), + ]); + _stepper.Activated += (_, _) => StarsChanged?.Invoke(this, EventArgs.Empty); + + // A rating of 0 never fires a change notification on its own, so start silent rather than log a phantom change. + using (SuppressChangeNotifications()) + { + Stars = 0; + } + + PropertyChanging += static (_, e) => Console.WriteLine($"StarRatingControl.{e.PropertyName} is changing."); + PropertyChanged += (_, e) => Console.WriteLine($"StarRatingControl.{e.PropertyName} changed to {Stars}."); + + _ = this.WhenActivated(d => + { + d(this.Bind(ViewModel, static vm => vm.Rating, static v => v.Stars)); + d(Changed.Subscribe(static change => Console.WriteLine($"StarRatingControl.{change.PropertyName} changed (Changed stream)."))); + d(Changing.Subscribe(static change => Console.WriteLine($"StarRatingControl.{change.PropertyName} is changing (Changing stream)."))); + d(ThrownExceptions.Subscribe(static error => Console.WriteLine($"StarRatingControl threw: {error.Message}"))); + d(Activated.Subscribe(static _ => Console.WriteLine("StarRatingControl activated."))); + d(Deactivated.Subscribe(static _ => Console.WriteLine("StarRatingControl deactivated."))); + }); + } +} diff --git a/src/examples/Documentation/Pages/platform-apple/platform-apple.csproj b/src/examples/Documentation/Pages/platform-apple/platform-apple.csproj new file mode 100644 index 0000000000..883dc97699 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-apple/platform-apple.csproj @@ -0,0 +1,66 @@ + + + + + net10.0-ios;net10.0-macos + + net.reactiveui.documentation.platformapple + ReactiveUI Apple Examples + 1 + 1.0 + + false + + false + false + false + + + + 15.0 + + iossimulator-arm64 + iossimulator-x64 + None + + + + 12.0 + + osx-arm64 + osx-x64 + None + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/examples/Documentation/Pages/platform-blend-drawing/SmokeTest.cs b/src/examples/Documentation/Pages/platform-blend-drawing/SmokeTest.cs index e423966f16..1bc3488ec3 100644 --- a/src/examples/Documentation/Pages/platform-blend-drawing/SmokeTest.cs +++ b/src/examples/Documentation/Pages/platform-blend-drawing/SmokeTest.cs @@ -52,7 +52,14 @@ public static void Run() private static void ShowSchedulerOverrideForTesting() { Border probeElement = new(); + Border targetElement = new(); FollowObservableStateBehavior stateBehavior = new() { SchedulerOverride = Sequencer.Immediate }; + + // A test that builds the behavior in code, rather than XAML, sets TargetObject through its dependency + // property field with SetValue; the window's XAML sets the same property with a binding instead. + stateBehavior.SetValue(FollowObservableStateBehavior.TargetObjectProperty, targetElement); + Console.WriteLine($"TargetObject via SetValue: {stateBehavior.GetValue(FollowObservableStateBehavior.TargetObjectProperty) == targetElement}"); + stateBehavior.Attach(probeElement); stateBehavior.StateObservable = Signal.Emit("Stormy"); Console.WriteLine("FollowObservableStateBehavior.SchedulerOverride delivered without a dispatcher pump."); diff --git a/src/examples/Documentation/Pages/platform-maui/LoggingRecipeViewHost.cs b/src/examples/Documentation/Pages/platform-maui/LoggingRecipeViewHost.cs new file mode 100644 index 0000000000..438cc26e08 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-maui/LoggingRecipeViewHost.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics; +using ReactiveUI.Maui; + +namespace ReactiveUI.Documentation.PlatformMaui; + +/// A for that counts each resolution. +[DebuggerDisplay("LoggingRecipeViewHost: {ResolutionCount} resolutions")] +public sealed class LoggingRecipeViewHost : ViewModelViewHost +{ + /// Gets the number of times this host has resolved a view. + public int ResolutionCount { get; private set; } + + /// + protected override void ResolveViewForViewModel(object? viewModel, string? contract) + { + ResolutionCount++; + base.ResolveViewForViewModel(viewModel, contract); + } +} diff --git a/src/examples/Documentation/Pages/platform-maui/LoggingViewModelViewHost.cs b/src/examples/Documentation/Pages/platform-maui/LoggingViewModelViewHost.cs new file mode 100644 index 0000000000..8e0137d16a --- /dev/null +++ b/src/examples/Documentation/Pages/platform-maui/LoggingViewModelViewHost.cs @@ -0,0 +1,28 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics; +using ReactiveUI.Maui; + +namespace ReactiveUI.Documentation.PlatformMaui; + +/// A that records the view model type it resolved a view for each time. +[DebuggerDisplay("LoggingViewModelViewHost: {ResolvedViewModelTypes.Count} resolved")] +public sealed class LoggingViewModelViewHost : ViewModelViewHost +{ + /// Gets the view model type names resolved so far, in order. + public List ResolvedViewModelTypes { get; } = []; + + /// + protected override void ResolveViewForViewModel(object? viewModel, string? contract) + { + if (viewModel is not null) + { + ResolvedViewModelTypes.Add(viewModel.GetType().Name); + } + + base.ResolveViewForViewModel(viewModel, contract); + } +} diff --git a/src/examples/Documentation/Pages/platform-maui/ViewModelViewHostExamples.cs b/src/examples/Documentation/Pages/platform-maui/ViewModelViewHostExamples.cs index 06b9133ad7..7b1c9eee95 100644 --- a/src/examples/Documentation/Pages/platform-maui/ViewModelViewHostExamples.cs +++ b/src/examples/Documentation/Pages/platform-maui/ViewModelViewHostExamples.cs @@ -159,4 +159,34 @@ public static void GenericHostTypesTheViewModelProperty() // 3 // True } + + /// Overriding ResolveViewForViewModel lets a host observe each resolution before the base class runs it. + public static void OverridingResolveViewForViewModelObservesEachResolution() + { + AppLocator.CurrentMutable.Register>(static () => new RecipeListContentView()); + + LoggingViewModelViewHost host = new(); + host.ViewModel = new RecipeListViewModel(new RecipeBookScreen()); + host.ViewModel = null; + + Console.WriteLine(string.Join(", ", host.ResolvedViewModelTypes)); + + // Output: + // RecipeListViewModel + } + + /// overrides the same method, so a generic host can observe resolutions too. + public static void GenericHostOverridesResolveViewForViewModelToo() + { + AppLocator.CurrentMutable.Register>(static () => new RecipeListContentView()); + + LoggingRecipeViewHost host = new(); + host.ViewModel = new RecipeListViewModel(new RecipeBookScreen()); + host.ViewModel = new RecipeListViewModel(new RecipeBookScreen()); + + Console.WriteLine(host.ResolutionCount); + + // Output: + // 2 + } } diff --git a/src/examples/Documentation/Pages/platform-maui/VisibilityConverterExamples.cs b/src/examples/Documentation/Pages/platform-maui/VisibilityConverterExamples.cs index 07696ab95a..ecbc3278cb 100644 --- a/src/examples/Documentation/Pages/platform-maui/VisibilityConverterExamples.cs +++ b/src/examples/Documentation/Pages/platform-maui/VisibilityConverterExamples.cs @@ -55,4 +55,18 @@ public static void VisibilityToBooleanConvertsBack() // False // False } + + /// GetAffinityForObjects reports how well each converter matches a binding; ReactiveUI's binding resolution calls it to pick a converter. + public static void GetAffinityForObjectsReportsTheBuiltInScore() + { + BooleanToVisibilityTypeConverter toVisibility = new(); + VisibilityToBooleanTypeConverter toBoolean = new(); + + Console.WriteLine(toVisibility.GetAffinityForObjects()); + Console.WriteLine(toBoolean.GetAffinityForObjects()); + + // Output: + // 2 + // 2 + } } diff --git a/src/examples/Documentation/Pages/platform-winforms/ContentSetMethodConvertersExamples.cs b/src/examples/Documentation/Pages/platform-winforms/ContentSetMethodConvertersExamples.cs new file mode 100644 index 0000000000..a21d4b9204 --- /dev/null +++ b/src/examples/Documentation/Pages/platform-winforms/ContentSetMethodConvertersExamples.cs @@ -0,0 +1,77 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Winforms; + +namespace ReactiveUI.Documentation.PlatformWinforms; + +/// +/// Shows and , the +/// set-method converters Bind uses to fill a 's or 's +/// Controls collection from a source, since neither collection has a normal property setter. +/// +public static class ContentSetMethodConvertersExamples +{ + /// The panel converter matches a source that holds s and a target of ; nothing else. + public static void PanelConverterMatchesAControlCollection() + { + PanelSetMethodBindingConverter converter = new(); + + Console.WriteLine(converter.GetAffinityForObjects(typeof(List