Repository: jonathanpeppers/spice Branch: main Commit: b05aeaa7fadd Files: 358 Total size: 1.4 MB Directory structure: gitextract_5fqaxoqu/ ├── .config/ │ └── dotnet-tools.json ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── copilot-instructions.md │ ├── dependabot.yml │ └── workflows/ │ ├── copilot-setup-steps.yml │ └── spice.yml ├── .gitignore ├── .vscode/ │ └── settings.json ├── Directory.Build.props ├── Directory.Build.targets ├── LICENSE ├── NuGet.config ├── README.md ├── docs/ │ ├── DATA-BINDING-SPEC.md │ ├── MAUI-CONTROLS-COMPARISON.md │ ├── NAVIGATION-SPEC.md │ ├── THEME-SPEC.md │ ├── VIEW-LIFECYCLE-SPEC.md │ └── spice.pptx ├── global.json ├── samples/ │ ├── HeadToHeadMaui/ │ │ ├── App.xaml │ │ ├── App.xaml.cs │ │ ├── AppShell.xaml │ │ ├── AppShell.xaml.cs │ │ ├── HeadToHeadMaui.csproj │ │ ├── HeadToHeadMaui.sln │ │ ├── MainPage.xaml │ │ ├── MainPage.xaml.cs │ │ ├── MauiProgram.cs │ │ ├── Platforms/ │ │ │ ├── Android/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── MainActivity.cs │ │ │ │ ├── MainApplication.cs │ │ │ │ └── Resources/ │ │ │ │ └── values/ │ │ │ │ └── colors.xml │ │ │ ├── MacCatalyst/ │ │ │ │ ├── AppDelegate.cs │ │ │ │ ├── Info.plist │ │ │ │ └── Program.cs │ │ │ ├── Tizen/ │ │ │ │ ├── Main.cs │ │ │ │ └── tizen-manifest.xml │ │ │ ├── Windows/ │ │ │ │ ├── App.xaml │ │ │ │ ├── App.xaml.cs │ │ │ │ ├── Package.appxmanifest │ │ │ │ └── app.manifest │ │ │ └── iOS/ │ │ │ ├── AppDelegate.cs │ │ │ ├── Info.plist │ │ │ └── Program.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ └── Resources/ │ │ ├── Raw/ │ │ │ └── AboutAssets.txt │ │ └── Styles/ │ │ ├── Colors.xaml │ │ └── Styles.xaml │ ├── HeadToHeadSpice/ │ │ ├── App.cs │ │ ├── GlobalUsings.cs │ │ ├── HeadToHeadSpice.csproj │ │ └── Platforms/ │ │ ├── Android/ │ │ │ ├── AndroidManifest.xml │ │ │ └── MainActivity.cs │ │ └── iOS/ │ │ ├── AppDelegate.cs │ │ ├── Info.plist │ │ └── Program.cs │ ├── README.md │ ├── Spice.BlazorSample/ │ │ ├── App.cs │ │ ├── GlobalUsings.cs │ │ ├── Main.razor │ │ ├── Pages/ │ │ │ └── Index.razor │ │ ├── Platforms/ │ │ │ ├── Android/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── MainActivity.cs │ │ │ └── iOS/ │ │ │ ├── AppDelegate.cs │ │ │ ├── Info.plist │ │ │ └── Program.cs │ │ ├── Shared/ │ │ │ ├── MainLayout.razor │ │ │ ├── MainLayout.razor.css │ │ │ ├── NavMenu.razor │ │ │ └── NavMenu.razor.css │ │ ├── Spice.BlazorSample.csproj │ │ ├── _Imports.razor │ │ └── wwwroot/ │ │ ├── css/ │ │ │ ├── app.css │ │ │ └── open-iconic/ │ │ │ ├── FONT-LICENSE │ │ │ ├── ICON-LICENSE │ │ │ ├── README.md │ │ │ └── font/ │ │ │ └── fonts/ │ │ │ └── open-iconic.otf │ │ └── index.html │ ├── Spice.Scenarios/ │ │ ├── App.cs │ │ ├── GlobalUsings.cs │ │ ├── Platforms/ │ │ │ ├── Android/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── MainActivity.cs │ │ │ └── iOS/ │ │ │ ├── AppDelegate.cs │ │ │ ├── Info.plist │ │ │ └── Program.cs │ │ ├── Scenarios/ │ │ │ ├── ActivityIndicatorScenario.cs │ │ │ ├── BorderScenario.cs │ │ │ ├── BoxViewScenario.cs │ │ │ ├── CheckBoxScenario.cs │ │ │ ├── CollectionViewScenario.cs │ │ │ ├── ContentViewScenario.cs │ │ │ ├── DatePickerScenario.cs │ │ │ ├── EditorScenario.cs │ │ │ ├── EntryScenario.cs │ │ │ ├── GhostButtonScenario.cs │ │ │ ├── HelloWorldScenario.cs │ │ │ ├── ImageButtonScenario.cs │ │ │ ├── MarginScenario.cs │ │ │ ├── PickerScenario.cs │ │ │ ├── ProgressBarScenario.cs │ │ │ ├── RadioButtonScenario.cs │ │ │ ├── RefreshViewScenario.cs │ │ │ ├── ScrollViewScenario.cs │ │ │ ├── SearchBarScenario.cs │ │ │ ├── SliderScenario.cs │ │ │ ├── SwipeViewScenario.cs │ │ │ ├── SwitchScenario.cs │ │ │ ├── ThemeScenario.cs │ │ │ ├── TimePickerScenario.cs │ │ │ └── WebViewScenario.cs │ │ └── Spice.Scenarios.csproj │ └── samples.slnx ├── sizes/ │ ├── com.companyname.Hello-Signed.apkdesc │ ├── com.companyname.HelloBlazor-Signed.apkdesc │ └── startup.md ├── spice.slnx ├── src/ │ ├── ProfiledAot/ │ │ ├── Directory.Build.props │ │ ├── Directory.Build.targets │ │ ├── README.md │ │ ├── build.proj │ │ └── shared/ │ │ └── nuget.config │ ├── Spice/ │ │ ├── Blazor/ │ │ │ ├── BlazorWebView.cs │ │ │ ├── SpiceDispatcher.cs │ │ │ ├── SpiceServiceProvider.cs │ │ │ └── UriExtensions.cs │ │ ├── Core/ │ │ │ ├── ActivityIndicator.cs │ │ │ ├── Application.cs │ │ │ ├── BindingExtensions.cs │ │ │ ├── Border.cs │ │ │ ├── BoxView.cs │ │ │ ├── Button.cs │ │ │ ├── CheckBox.cs │ │ │ ├── CollectionView.cs │ │ │ ├── ColumnDefinition.cs │ │ │ ├── ContentView.cs │ │ │ ├── DatePicker.cs │ │ │ ├── Editor.cs │ │ │ ├── Entry.cs │ │ │ ├── Grid.cs │ │ │ ├── GridLength.cs │ │ │ ├── GridUnitType.cs │ │ │ ├── Image.cs │ │ │ ├── ImageButton.cs │ │ │ ├── Label.cs │ │ │ ├── LayoutAlignment.cs │ │ │ ├── LayoutExpandFlag.cs │ │ │ ├── LayoutOptions.cs │ │ │ ├── NavigationView.cs │ │ │ ├── Orientation.cs │ │ │ ├── Picker.cs │ │ │ ├── PlatformAppearance.cs │ │ │ ├── ProgressBar.cs │ │ │ ├── RadioButton.cs │ │ │ ├── RefreshView.cs │ │ │ ├── RootComponent.cs │ │ │ ├── RowDefinition.cs │ │ │ ├── ScrollView.cs │ │ │ ├── SearchBar.cs │ │ │ ├── SelectionMode.cs │ │ │ ├── Slider.cs │ │ │ ├── StackLayout.cs │ │ │ ├── SwipeBehaviorOnInvoked.cs │ │ │ ├── SwipeDirection.cs │ │ │ ├── SwipeItem.cs │ │ │ ├── SwipeItems.cs │ │ │ ├── SwipeMode.cs │ │ │ ├── SwipeView.cs │ │ │ ├── Switch.cs │ │ │ ├── TabView.cs │ │ │ ├── Theme.cs │ │ │ ├── Thickness.cs │ │ │ ├── TimePicker.cs │ │ │ ├── TwoWayBindingExtensions.cs │ │ │ ├── View.cs │ │ │ └── WebView.cs │ │ ├── GlobalUsings.cs │ │ ├── MSBuild/ │ │ │ ├── Spice.Blazor.targets │ │ │ ├── Spice.props │ │ │ ├── Spice.targets │ │ │ ├── spice-blazor.aotprofile │ │ │ ├── spice-blazor.aotprofile.txt │ │ │ ├── spice.aotprofile │ │ │ └── spice.aotprofile.txt │ │ ├── Platforms/ │ │ │ ├── Android/ │ │ │ │ ├── ActivityIndicator.cs │ │ │ │ ├── Application.cs │ │ │ │ ├── Blazor/ │ │ │ │ │ ├── AndroidAssetFileProvider.cs │ │ │ │ │ ├── AndroidWebViewManager.cs │ │ │ │ │ ├── BlazorWebView.cs │ │ │ │ │ ├── SpiceBlazorWebViewClient.cs │ │ │ │ │ └── SpiceDispatcher.cs │ │ │ │ ├── Border.cs │ │ │ │ ├── BoxView.cs │ │ │ │ ├── Button.cs │ │ │ │ ├── CheckBox.cs │ │ │ │ ├── CollectionView.cs │ │ │ │ ├── ContentView.cs │ │ │ │ ├── DatePicker.cs │ │ │ │ ├── Editor.cs │ │ │ │ ├── Entry.cs │ │ │ │ ├── Grid.cs │ │ │ │ ├── Image.cs │ │ │ │ ├── ImageButton.cs │ │ │ │ ├── Interop.java │ │ │ │ ├── Label.cs │ │ │ │ ├── NavigationView.cs │ │ │ │ ├── Picker.cs │ │ │ │ ├── Platform.cs │ │ │ │ ├── PlatformAppearance.cs │ │ │ │ ├── PlatformExtensions.cs │ │ │ │ ├── ProgressBar.cs │ │ │ │ ├── RadioButton.cs │ │ │ │ ├── RefreshView.cs │ │ │ │ ├── ScrollView.cs │ │ │ │ ├── SearchBar.cs │ │ │ │ ├── Slider.cs │ │ │ │ ├── SpiceActivity.cs │ │ │ │ ├── StackLayout.cs │ │ │ │ ├── SwipeView.cs │ │ │ │ ├── Switch.cs │ │ │ │ ├── TabView.cs │ │ │ │ ├── TimePicker.cs │ │ │ │ ├── Transforms.xml │ │ │ │ ├── View.cs │ │ │ │ └── WebView.cs │ │ │ └── iOS/ │ │ │ ├── ActivityIndicator.cs │ │ │ ├── Application.cs │ │ │ ├── Blazor/ │ │ │ │ ├── BlazorWebView.cs │ │ │ │ ├── SpiceDispatcher.cs │ │ │ │ ├── iOSFileProvider.cs │ │ │ │ └── iOSWebViewManager.cs │ │ │ ├── Border.cs │ │ │ ├── BoxView.cs │ │ │ ├── Button.cs │ │ │ ├── CheckBox.cs │ │ │ ├── CollectionView.cs │ │ │ ├── ConstraintHelper.cs │ │ │ ├── ContentView.cs │ │ │ ├── DatePicker.cs │ │ │ ├── Editor.cs │ │ │ ├── Entry.cs │ │ │ ├── Grid.cs │ │ │ ├── Image.cs │ │ │ ├── ImageButton.cs │ │ │ ├── Label.cs │ │ │ ├── NavigationView.cs │ │ │ ├── Picker.cs │ │ │ ├── Platform.cs │ │ │ ├── PlatformAppearance.cs │ │ │ ├── PlatformExtensions.cs │ │ │ ├── ProgressBar.cs │ │ │ ├── RadioButton.cs │ │ │ ├── RefreshView.cs │ │ │ ├── ScrollView.cs │ │ │ ├── SearchBar.cs │ │ │ ├── Slider.cs │ │ │ ├── SpiceAppDelegate.cs │ │ │ ├── SpiceViewController.cs │ │ │ ├── StackLayout.cs │ │ │ ├── SwipeView.cs │ │ │ ├── Switch.cs │ │ │ ├── TabView.cs │ │ │ ├── TimePicker.cs │ │ │ ├── View.cs │ │ │ └── WebView.cs │ │ ├── README.md │ │ └── Spice.csproj │ └── Spice.Templates/ │ ├── Spice.Templates.csproj │ └── templates/ │ ├── spice/ │ │ ├── .template.config/ │ │ │ └── template.json │ │ ├── App.cs │ │ ├── GlobalUsings.cs │ │ ├── Hello.csproj │ │ └── Platforms/ │ │ ├── Android/ │ │ │ ├── AndroidManifest.xml │ │ │ └── MainActivity.cs │ │ └── iOS/ │ │ ├── AppDelegate.cs │ │ ├── Info.plist │ │ └── Program.cs │ └── spice-blazor/ │ ├── .template.config/ │ │ └── template.json │ ├── App.cs │ ├── GlobalUsings.cs │ ├── HelloBlazor.csproj │ ├── Main.razor │ ├── Pages/ │ │ └── Index.razor │ ├── Platforms/ │ │ ├── Android/ │ │ │ ├── AndroidManifest.xml │ │ │ └── MainActivity.cs │ │ └── iOS/ │ │ ├── AppDelegate.cs │ │ ├── Info.plist │ │ └── Program.cs │ ├── Shared/ │ │ ├── MainLayout.razor │ │ ├── MainLayout.razor.css │ │ ├── NavMenu.razor │ │ └── NavMenu.razor.css │ ├── _Imports.razor │ └── wwwroot/ │ ├── css/ │ │ ├── app.css │ │ └── open-iconic/ │ │ ├── FONT-LICENSE │ │ ├── ICON-LICENSE │ │ ├── README.md │ │ └── font/ │ │ └── fonts/ │ │ └── open-iconic.otf │ └── index.html └── tests/ └── Spice.Tests/ ├── ActivityIndicatorTests.cs ├── ApplicationTests.cs ├── AutomationIdTests.cs ├── BindingExtensionsTests.cs ├── BorderTests.cs ├── BoxViewTests.cs ├── ButtonTests.cs ├── CheckBoxTests.cs ├── CollectionViewTests.cs ├── ContentViewTests.cs ├── DatePickerTests.cs ├── EditorTests.cs ├── EntryTests.cs ├── GridLengthTests.cs ├── GridTests.cs ├── ImageButtonTests.cs ├── ImageTests.cs ├── LabelTests.cs ├── LayoutOptionsTests.cs ├── MarginTests.cs ├── ModalPresentationTests.cs ├── NavigationViewTests.cs ├── PickerTests.cs ├── Platforms/ │ ├── Android/ │ │ ├── AndroidManifest.xml │ │ ├── MainActivity.cs │ │ ├── TestDevice.cs │ │ ├── TestEntryPoint.cs │ │ └── TestInstrumentation.cs │ └── iOS/ │ ├── AppDelegate.cs │ ├── Info.plist │ ├── Program.cs │ ├── TestDevice.cs │ └── TestEntryPoint.cs ├── ProgressBarTests.cs ├── PropertyChangedTests.cs ├── RadioButtonTests.cs ├── RefreshViewTests.cs ├── ScrollViewTests.cs ├── SearchBarTests.cs ├── SliderTests.cs ├── Spice.Tests.csproj ├── StackLayoutTests.cs ├── SwipeItemsTests.cs ├── SwipeViewTests.cs ├── SwitchTests.cs ├── TabViewTests.cs ├── TestViewModel.cs ├── ThemeTests.cs ├── ThicknessTests.cs ├── TimePickerTests.cs ├── TwoWayBindingExtensionsTests.cs ├── Usings.cs ├── ViewLifecycleTests.cs ├── ViewOpacityTests.cs ├── ViewSizeTests.cs ├── ViewTests.cs ├── ViewVisibilityTests.cs └── WebViewTests.cs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .config/dotnet-tools.json ================================================ { "version": 1, "isRoot": true, "tools": { "androidsdk.tool": { "version": "0.25.0", "commands": [ "android" ], "rollForward": true }, "microsoft.dotnet.xharness.cli": { "version": "11.0.0-prerelease.26114.1", "commands": [ "xharness" ], "rollForward": true } } } ================================================ FILE: .editorconfig ================================================ root = true # All files [*] indent_style = tab # Xml files [*.xml] indent_style = space indent_size = 2 # C# files [*.cs] #### Core EditorConfig Options #### # Indentation and spacing indent_size = 4 tab_width = 4 # New line preferences end_of_line = crlf insert_final_newline = false #### .NET Coding Conventions #### [*.{cs,vb}] # Organize usings dotnet_separate_import_directive_groups = true dotnet_sort_system_directives_first = true file_header_template = unset # this. and Me. preferences dotnet_style_qualification_for_event = false:silent dotnet_style_qualification_for_field = false:silent dotnet_style_qualification_for_method = false:silent dotnet_style_qualification_for_property = false:silent # Language keywords vs BCL types preferences dotnet_style_predefined_type_for_locals_parameters_members = true:silent dotnet_style_predefined_type_for_member_access = true:silent # Parentheses preferences dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent # Modifier preferences dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent # Expression-level preferences dotnet_style_coalesce_expression = true:suggestion dotnet_style_collection_initializer = true:suggestion dotnet_style_explicit_tuple_names = true:suggestion dotnet_style_null_propagation = true:suggestion dotnet_style_object_initializer = true:suggestion dotnet_style_operator_placement_when_wrapping = beginning_of_line dotnet_style_prefer_auto_properties = true:suggestion dotnet_style_prefer_compound_assignment = true:suggestion dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion dotnet_style_prefer_conditional_expression_over_return = true:suggestion dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion dotnet_style_prefer_inferred_tuple_names = true:suggestion dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion dotnet_style_prefer_simplified_boolean_expressions = true:suggestion dotnet_style_prefer_simplified_interpolation = true:suggestion # Parameter preferences dotnet_code_quality_unused_parameters = all:suggestion # Suppression preferences dotnet_remove_unnecessary_suppression_exclusions = none #### C# Coding Conventions #### [*.cs] # var preferences csharp_style_var_elsewhere = false:silent csharp_style_var_for_built_in_types = false:silent csharp_style_var_when_type_is_apparent = false:silent # Expression-bodied members csharp_style_expression_bodied_accessors = true:silent csharp_style_expression_bodied_constructors = false:silent csharp_style_expression_bodied_indexers = true:silent csharp_style_expression_bodied_lambdas = true:suggestion csharp_style_expression_bodied_local_functions = false:silent csharp_style_expression_bodied_methods = false:silent csharp_style_expression_bodied_operators = false:silent csharp_style_expression_bodied_properties = true:silent # Pattern matching preferences csharp_style_pattern_matching_over_as_with_null_check = true:suggestion csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion csharp_style_prefer_not_pattern = true:suggestion csharp_style_prefer_pattern_matching = true:silent csharp_style_prefer_switch_expression = true:suggestion # Null-checking preferences csharp_style_conditional_delegate_call = true:suggestion # Modifier preferences csharp_prefer_static_local_function = true:warning csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent # Code-block preferences csharp_prefer_braces = true:silent csharp_prefer_simple_using_statement = true:suggestion # Expression-level preferences csharp_prefer_simple_default_expression = true:suggestion csharp_style_deconstructed_variable_declaration = true:suggestion csharp_style_inlined_variable_declaration = true:suggestion csharp_style_pattern_local_over_anonymous_function = true:suggestion csharp_style_prefer_index_operator = true:suggestion csharp_style_prefer_range_operator = true:suggestion csharp_style_throw_expression = true:suggestion csharp_style_unused_value_assignment_preference = discard_variable:suggestion csharp_style_unused_value_expression_statement_preference = discard_variable:silent # 'using' directive preferences csharp_using_directive_placement = outside_namespace:silent #### C# Formatting Rules #### # New line preferences csharp_new_line_before_catch = true csharp_new_line_before_else = true csharp_new_line_before_finally = true csharp_new_line_before_members_in_anonymous_types = true csharp_new_line_before_members_in_object_initializers = true csharp_new_line_before_open_brace = all csharp_new_line_between_query_expression_clauses = true # Indentation preferences csharp_indent_block_contents = true csharp_indent_braces = false csharp_indent_case_contents = true csharp_indent_case_contents_when_block = true csharp_indent_labels = one_less_than_current csharp_indent_switch_labels = true # Space preferences csharp_space_after_cast = false csharp_space_after_colon_in_inheritance_clause = true csharp_space_after_comma = true csharp_space_after_dot = false csharp_space_after_keywords_in_control_flow_statements = true csharp_space_after_semicolon_in_for_statement = true csharp_space_around_binary_operators = before_and_after csharp_space_around_declaration_statements = false csharp_space_before_colon_in_inheritance_clause = true csharp_space_before_comma = false csharp_space_before_dot = false csharp_space_before_open_square_brackets = false csharp_space_before_semicolon_in_for_statement = false csharp_space_between_empty_square_brackets = false csharp_space_between_method_call_empty_parameter_list_parentheses = false csharp_space_between_method_call_name_and_opening_parenthesis = false csharp_space_between_method_call_parameter_list_parentheses = false csharp_space_between_method_declaration_empty_parameter_list_parentheses = false csharp_space_between_method_declaration_name_and_open_parenthesis = false csharp_space_between_method_declaration_parameter_list_parentheses = false csharp_space_between_parentheses = false csharp_space_between_square_brackets = false # Wrapping preferences csharp_preserve_single_line_blocks = true csharp_preserve_single_line_statements = true #### Naming styles #### [*.{cs,vb}] # Naming rules dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion dotnet_naming_rule.events_should_be_pascalcase.symbols = events dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase # Symbol specifications dotnet_naming_symbols.interfaces.applicable_kinds = interface dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected dotnet_naming_symbols.interfaces.required_modifiers = dotnet_naming_symbols.enums.applicable_kinds = enum dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected dotnet_naming_symbols.enums.required_modifiers = dotnet_naming_symbols.events.applicable_kinds = event dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected dotnet_naming_symbols.events.required_modifiers = dotnet_naming_symbols.methods.applicable_kinds = method dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected dotnet_naming_symbols.methods.required_modifiers = dotnet_naming_symbols.properties.applicable_kinds = property dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected dotnet_naming_symbols.properties.required_modifiers = dotnet_naming_symbols.public_fields.applicable_kinds = field dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal dotnet_naming_symbols.public_fields.required_modifiers = dotnet_naming_symbols.private_fields.applicable_kinds = field dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected dotnet_naming_symbols.private_fields.required_modifiers = dotnet_naming_symbols.private_static_fields.applicable_kinds = field dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected dotnet_naming_symbols.private_static_fields.required_modifiers = static dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected dotnet_naming_symbols.types_and_namespaces.required_modifiers = dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected dotnet_naming_symbols.non_field_members.required_modifiers = dotnet_naming_symbols.type_parameters.applicable_kinds = namespace dotnet_naming_symbols.type_parameters.applicable_accessibilities = * dotnet_naming_symbols.type_parameters.required_modifiers = dotnet_naming_symbols.private_constant_fields.applicable_kinds = field dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected dotnet_naming_symbols.private_constant_fields.required_modifiers = const dotnet_naming_symbols.local_variables.applicable_kinds = local dotnet_naming_symbols.local_variables.applicable_accessibilities = local dotnet_naming_symbols.local_variables.required_modifiers = dotnet_naming_symbols.local_constants.applicable_kinds = local dotnet_naming_symbols.local_constants.applicable_accessibilities = local dotnet_naming_symbols.local_constants.required_modifiers = const dotnet_naming_symbols.parameters.applicable_kinds = parameter dotnet_naming_symbols.parameters.applicable_accessibilities = * dotnet_naming_symbols.parameters.required_modifiers = dotnet_naming_symbols.public_constant_fields.applicable_kinds = field dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal dotnet_naming_symbols.public_constant_fields.required_modifiers = const dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static dotnet_naming_symbols.local_functions.applicable_kinds = local_function dotnet_naming_symbols.local_functions.applicable_accessibilities = * dotnet_naming_symbols.local_functions.required_modifiers = # Naming styles dotnet_naming_style.pascalcase.required_prefix = dotnet_naming_style.pascalcase.required_suffix = dotnet_naming_style.pascalcase.word_separator = dotnet_naming_style.pascalcase.capitalization = pascal_case dotnet_naming_style.ipascalcase.required_prefix = I dotnet_naming_style.ipascalcase.required_suffix = dotnet_naming_style.ipascalcase.word_separator = dotnet_naming_style.ipascalcase.capitalization = pascal_case dotnet_naming_style.tpascalcase.required_prefix = T dotnet_naming_style.tpascalcase.required_suffix = dotnet_naming_style.tpascalcase.word_separator = dotnet_naming_style.tpascalcase.capitalization = pascal_case dotnet_naming_style._camelcase.required_prefix = _ dotnet_naming_style._camelcase.required_suffix = dotnet_naming_style._camelcase.word_separator = dotnet_naming_style._camelcase.capitalization = camel_case dotnet_naming_style.camelcase.required_prefix = dotnet_naming_style.camelcase.required_suffix = dotnet_naming_style.camelcase.word_separator = dotnet_naming_style.camelcase.capitalization = camel_case dotnet_naming_style.s_camelcase.required_prefix = s_ dotnet_naming_style.s_camelcase.required_suffix = dotnet_naming_style.s_camelcase.word_separator = dotnet_naming_style.s_camelcase.capitalization = camel_case # CommunityToolkit.Mvvm dotnet_analyzer_diagnostic.category-CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator.severity = none ================================================ FILE: .gitattributes ================================================ # Auto detect text files and perform LF normalization * text=auto ================================================ FILE: .github/copilot-instructions.md ================================================ # Spice 🌶 — Copilot Instructions Minimal cross-platform mobile UI framework for .NET (iOS + Android). **No** `Microsoft.Maui.Controls`, XAML, DI, MVVM, data-binding, or `System.Reflection`. Views are POCOs mapping to native controls. Uses only `Microsoft.Maui.Graphics` (`Color`) and MAUI's SingleProject + Resizetizer. ## Partial Class Pattern (Core Architecture) Each view = 3 files: `Core/{Type}.cs` (cross-platform, `[ObservableProperty]`), `Platforms/iOS/{Type}.cs` (UIKit), `Platforms/Android/{Type}.cs` (Android widgets). Platform partials implement `partial void On{Prop}Changed`. Platform files excluded from other TFMs via `Spice.targets`. Follow `Image`/`Label` as templates for new controls. ## Conventions - **Namespace**: `Spice` only — no sub-namespaces - **Properties**: `[ObservableProperty]` on `_camelCase` fields → generates `PascalCase` + `On{Prop}Changed` - **Implicit operators**: `public static implicit operator NativeType(SpiceType)` in platform partials - **Lazy views**: `Lazy _nativeView` with factory function - **XML docs**: Required on all public API; include platform mappings (e.g. `Android.Widget.TextView / UIKit.UILabel`) - **GlobalUsings**: `CommunityToolkit.Mvvm.ComponentModel` + `Color = Microsoft.Maui.Graphics.Color` - **`VANILLA` define**: Active on `net10.0` only; prefer partials over `#if` - **Blazor**: `Blazor/` folders at library+platform levels; `BlazorWebView` extends `WebView` - **iOS memory**: NSObject subclasses must not hold strong references to other NSObjects (causes cycles the garbage collector cannot break). Use `WeakReference` for any NSObject-typed field/property in an NSObject subclass. Enforced by [`MemoryAnalyzers`](https://github.com/jonathanpeppers/memory-analyzers) (MEM0001–MEM0003). ## Layout ``` src/Spice/Core/ cross-platform views src/Spice/Platforms/ iOS + Android partials, entry points src/Spice/Blazor/ cross-platform Blazor; platform Blazor in Platforms/*/Blazor/ src/Spice/MSBuild/ Spice.props (capabilities), Spice.targets (inlined SingleProject) samples/ Spice.Scenarios, Spice.BlazorSample, HeadToHead* tests/Spice.Tests/ xUnit on net10.0 — views tested as POCOs, no device needed sizes/ .apkdesc baselines for CI apkdiff (10% APK / 15% content thresholds) src/Spice.Templates/ dotnet new spice / spice-blazor ``` ## Build & Test ```sh dotnet build # all TFMs (needs maui-android + maui-ios workloads) dotnet build -f net10.0-android -t:Run # run Android dotnet build -f net10.0-ios -t:Run # run iOS dotnet test tests/Spice.Tests/Spice.Tests.csproj # unit tests (net10.0, no device) ``` ## Entry Points - **iOS**: Scene-based. `SpiceAppDelegate` + `SpiceSceneDelegate`. Consumers: `class AppDelegate : SpiceAppDelegate { }`. Requires `UIApplicationSceneManifest` in Info.plist. Window via `Platform.Window`. - **Android**: `SpiceActivity : AppCompatActivity`. Sets `Platform.Context`. Consumers call `SetContentView(new App())`. ## MSBuild `Spice.targets` inlines MAUI's SingleProject logic (platform folder filtering, IDE capabilities) because Spice doesn't reference MAUI Controls. Do not add a `Microsoft.Maui.Controls` dependency. ## CI `macos-latest`: build → apkdiff size check → test → pack. Update baselines: `apkdiff -s --save-description-2=sizes/{name}.apkdesc`. ================================================ FILE: .github/dependabot.yml ================================================ # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "nuget" directory: "/" # Location of package manifests schedule: interval: "daily" ================================================ FILE: .github/workflows/copilot-setup-steps.yml ================================================ name: "Copilot Setup Steps" on: workflow_dispatch: push: paths: - .github/workflows/copilot-setup-steps.yml pull_request: paths: - .github/workflows/copilot-setup-steps.yml jobs: copilot-setup-steps: runs-on: ubuntu-latest permissions: contents: read env: WORKLOAD_VERSION: 10.0.103 steps: - name: checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: install .NET uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - name: install workloads run: dotnet workload install maui-android --version $WORKLOAD_VERSION ================================================ FILE: .github/workflows/spice.yml ================================================ name: build on: push: branches: [ "main" ] pull_request: branches: [ "main" ] jobs: build: runs-on: macos-latest env: WORKLOAD_VERSION: 10.0.103 ValidateXcodeVersion: false APK_PERCENTAGE_REGRESSION: 10 CONTENT_PERCENTAGE_REGRESSION: 15 steps: - name: checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: select Xcode version uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable - name: install .NET uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - name: cache NuGet packages uses: actions/cache@v4 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.slnx', '**/Directory.Build.props', '**/Directory.Build.targets') }} restore-keys: | ${{ runner.os }}-nuget- - name: install workloads run: dotnet workload install maui-android maui-ios --version $WORKLOAD_VERSION - name: restore all projects run: | dotnet restore spice.slnx dotnet restore samples/samples.slnx - name: build main solution (Release) run: dotnet build spice.slnx -c Release --no-restore -bl:bin/build-main.binlog -maxcpucount - name: build samples (Debug, faster) run: dotnet build samples/samples.slnx -c Debug --no-restore -bl:bin/build-samples.binlog -maxcpucount - name: tests run: dotnet test tests/Spice.Tests/Spice.Tests.csproj -f net10.0 -c Release --no-build - name: pack run: dotnet pack spice.slnx -c Release --no-build -bl:bin/pack.binlog - name: build templates run: | dotnet new install src/Spice.Templates mkdir -p bin/templates && cd bin/templates dotnet new spice -n Hello dotnet new spice-blazor -n HelloBlazor TARGETS='$(MSBuildThisFileDirectory)packages$(MSBuildThisFileDirectory)../../../bin/Release/true' echo $TARGETS > Hello/Directory.Build.targets echo $TARGETS > HelloBlazor/Directory.Build.targets echo '' > Directory.Build.props echo '' > Directory.Build.targets dotnet build Hello/Hello.csproj -f net10.0-android -c Release -r android-arm64 dotnet build HelloBlazor/HelloBlazor.csproj -f net10.0-android -c Release -r android-arm64 - name: run apkdiff run: | dotnet tool install --global apkdiff apkdiff -s --save-description-2=bin/com.companyname.Hello-Signed.apkdesc --descrease-is-regression --test-apk-percentage-regression=$APK_PERCENTAGE_REGRESSION --test-content-percentage-regression=$CONTENT_PERCENTAGE_REGRESSION sizes/com.companyname.Hello-Signed.apkdesc bin/templates/Hello/bin/Release/net10.0-android/android-arm64/com.companyname.Hello-Signed.apk apkdiff -s --save-description-2=bin/com.companyname.HelloBlazor-Signed.apkdesc --descrease-is-regression --test-apk-percentage-regression=$APK_PERCENTAGE_REGRESSION --test-content-percentage-regression=$CONTENT_PERCENTAGE_REGRESSION sizes/com.companyname.HelloBlazor-Signed.apkdesc bin/templates/HelloBlazor/bin/Release/net10.0-android/android-arm64/com.companyname.HelloBlazor-Signed.apk - name: artifacts if: always() uses: actions/upload-artifact@v4 with: name: nupkgs path: bin device-tests-android: runs-on: ubuntu-latest env: WORKLOAD_VERSION: 10.0.103 steps: - name: checkout uses: actions/checkout@v4 - name: enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: install .NET uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - name: cache NuGet packages uses: actions/cache@v4 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.slnx', '**/Directory.Build.props', '**/Directory.Build.targets') }} restore-keys: | ${{ runner.os }}-nuget- - name: install workloads run: dotnet workload install maui-android --version $WORKLOAD_VERSION - name: restore .NET tools run: dotnet tool restore - name: restore test app run: dotnet msbuild tests/Spice.Tests/Spice.Tests.csproj -t:Restore -p:TargetFramework=net10.0-android -p:RuntimeIdentifier=android-x64 - name: build test app run: dotnet build tests/Spice.Tests/Spice.Tests.csproj -f net10.0-android -c Release -r android-x64 --no-restore -bl:bin/build-device-tests-android.binlog - name: run Android device tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 target: google_apis arch: x86_64 script: dotnet xharness android test --app="tests/Spice.Tests/bin/Release/net10.0-android/android-x64/com.jonathanpeppers.spice.tests-Signed.apk" --package-name="com.jonathanpeppers.spice.tests" --instrumentation="com.jonathanpeppers.spice.tests.TestInstrumentation" --output-directory=bin/device-test-results/android --timeout=00:10:00 - name: publish test results if: always() uses: actions/upload-artifact@v4 with: name: device-test-results-android path: bin/device-test-results/android device-tests-ios: runs-on: macos-latest env: WORKLOAD_VERSION: 10.0.103 ValidateXcodeVersion: false steps: - name: checkout uses: actions/checkout@v4 - name: select Xcode version uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable - name: install .NET uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - name: cache NuGet packages uses: actions/cache@v4 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.slnx', '**/Directory.Build.props', '**/Directory.Build.targets') }} restore-keys: | ${{ runner.os }}-nuget- - name: install workloads run: dotnet workload install maui-ios --version $WORKLOAD_VERSION - name: restore .NET tools run: dotnet tool restore - name: restore test app run: dotnet msbuild tests/Spice.Tests/Spice.Tests.csproj -t:Restore -p:TargetFramework=net10.0-ios -p:RuntimeIdentifier=iossimulator-arm64 - name: build test app run: dotnet build tests/Spice.Tests/Spice.Tests.csproj -f net10.0-ios -c Release -r iossimulator-arm64 --no-restore -bl:bin/build-device-tests-ios.binlog - name: run iOS device tests run: > dotnet xharness apple test --app="tests/Spice.Tests/bin/Release/net10.0-ios/iossimulator-arm64/Spice.Tests.app" --output-directory=bin/device-test-results/ios --target=ios-simulator-64 --timeout=00:15:00 --launch-timeout=00:10:00 --communication-channel=Network - name: collect simulator logs if: always() run: | # Grab the most recent simulator device log UDID=$(xcrun simctl list devices booted -j | python3 -c "import sys,json; devs=[d for r in json.load(sys.stdin)['devices'].values() for d in r if d['state']=='Booted']; print(devs[0]['udid'] if devs else '')" 2>/dev/null || true) if [ -n "$UDID" ]; then LOG_DIR="$HOME/Library/Logs/DiagnosticReports" mkdir -p bin/device-test-results/ios cp "$LOG_DIR"/*.ips bin/device-test-results/ios/ 2>/dev/null || true # Also grab the system log from the simulator xcrun simctl spawn "$UDID" log show --last 5m --predicate 'process == "Spice.Tests"' > bin/device-test-results/ios/simulator-system.log 2>&1 || true fi - name: publish test results if: always() uses: actions/upload-artifact@v4 with: name: device-test-results-ios path: bin/device-test-results/ios ================================================ FILE: .gitignore ================================================ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore # User-specific files *.rsuser *.suo *.user *.userosscache *.sln.docstates # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs # Mono auto generated files mono_crash.* # Build results [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ x64/ x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ [Ll]ogs/ # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ # Visual Studio 2017 auto generated files Generated\ Files/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* # NUnit *.VisualState.xml TestResult.xml nunit-*.xml # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c # Benchmark Results BenchmarkDotNet.Artifacts/ # .NET Core project.lock.json project.fragment.lock.json artifacts/ # ASP.NET Scaffolding ScaffoldingReadMe.txt # StyleCop StyleCopReport.xml # Files built by Visual Studio *_i.c *_p.c *_h.h *.ilk *.meta *.obj *.iobj *.pch *.pdb *.ipdb *.pgc *.pgd *.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj *_wpftmp.csproj *.log *.tlog *.vspscc *.vssscc .builds *.pidb *.svclog *.scc # Chutzpah Test files _Chutzpah* # Visual C++ cache files ipch/ *.aps *.ncb *.opendb *.opensdf *.sdf *.cachefile *.VC.db *.VC.VC.opendb # Visual Studio profiler *.psess *.vsp *.vspx *.sap # Visual Studio Trace Files *.e2e # TFS 2012 Local Workspace $tf/ # Guidance Automation Toolkit *.gpState # ReSharper is a .NET coding add-in _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user # TeamCity is a build add-in _TeamCity* # DotCover is a Code Coverage Tool *.dotCover # AxoCover is a Code Coverage Tool .axoCover/* !.axoCover/settings.json # Coverlet is a free, cross platform Code Coverage Tool coverage*.json coverage*.xml coverage*.info # Visual Studio code coverage results *.coverage *.coveragexml # NCrunch _NCrunch_* .*crunch*.local.xml nCrunchTemp_* # MightyMoose *.mm.* AutoTest.Net/ # Web workbench (sass) .sass-cache/ # Installshield output folder [Ee]xpress/ # DocProject is a documentation generator add-in DocProject/buildhelp/ DocProject/Help/*.HxT DocProject/Help/*.HxC DocProject/Help/*.hhc DocProject/Help/*.hhk DocProject/Help/*.hhp DocProject/Help/Html2 DocProject/Help/html # Click-Once directory publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml # Note: Comment the next line if you want to checkin your web deploy settings, # but database connection strings (with potential passwords) will be unencrypted *.pubxml *.publishproj # Microsoft Azure Web App publish settings. Comment the next line if you want to # checkin your Azure Web App publish settings, but sensitive information contained # in these scripts will be unencrypted PublishScripts/ # NuGet Packages *.nupkg # NuGet Symbol Packages *.snupkg # The packages folder can be ignored because of Package Restore **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files *.nuget.props *.nuget.targets # Microsoft Azure Build Output csx/ *.build.csdef # Microsoft Azure Emulator ecf/ rcf/ # Windows Store app package directories and files AppPackages/ BundleArtifacts/ Package.StoreAssociation.xml _pkginfo.txt *.appx *.appxbundle *.appxupload # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !?*.[Cc]ache/ # Others ClientBin/ ~$* *~ *.dbmdl *.dbproj.schemaview *.jfm *.pfx *.publishsettings orleans.codegen.cs # Including strong name files can present a security risk # (https://github.com/github/gitignore/pull/2483#issue-259490424) #*.snk # Since there are multiple workflows, uncomment next line to ignore bower_components # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) #bower_components/ # RIA/Silverlight projects Generated_Code/ # Backup & report files from converting an old project file # to a newer Visual Studio version. Backup files are not needed, # because we have git ;-) _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm ServiceFabricBackup/ *.rptproj.bak # SQL Server files *.mdf *.ldf *.ndf # Business Intelligence projects *.rdl.data *.bim.layout *.bim_*.settings *.rptproj.rsuser *- [Bb]ackup.rdl *- [Bb]ackup ([0-9]).rdl *- [Bb]ackup ([0-9][0-9]).rdl # Microsoft Fakes FakesAssemblies/ # GhostDoc plugin setting file *.GhostDoc.xml # Node.js Tools for Visual Studio .ntvs_analysis.dat node_modules/ # Visual Studio 6 build log *.plg # Visual Studio 6 workspace options file *.opt # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw # Visual Studio 6 auto-generated project file (contains which files were open etc.) *.vbp # Visual Studio 6 workspace and project file (working project files containing files to include in project) *.dsw *.dsp # Visual Studio 6 technical files *.ncb *.aps # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts **/*.DesktopClient/ModelManifest.xml **/*.Server/GeneratedArtifacts **/*.Server/ModelManifest.xml _Pvt_Extensions # Paket dependency manager .paket/paket.exe paket-files/ # FAKE - F# Make .fake/ # CodeRush personal settings .cr/personal # Python Tools for Visual Studio (PTVS) __pycache__/ *.pyc # Cake - Uncomment if you are using it # tools/** # !tools/packages.config # Tabs Studio *.tss # Telerik's JustMock configuration file *.jmconfig # BizTalk build output *.btp.cs *.btm.cs *.odx.cs *.xsd.cs # OpenCover UI analysis results OpenCover/ # Azure Stream Analytics local run output ASALocalRun/ # MSBuild Binary and Structured Log *.binlog # NVidia Nsight GPU debugger configuration file *.nvuser # MFractors (Xamarin productivity tool) working folder .mfractor/ # Local History for Visual Studio .localhistory/ # Visual Studio History (VSHistory) files .vshistory/ # BeatPulse healthcheck temp database healthchecksdb # Backup folder for Package Reference Convert tool in Visual Studio 2017 MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ # Fody - auto-generated XML schema FodyWeavers.xsd # VS Code files for those working on multiple tools .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json *.code-workspace # Local History for Visual Studio Code .history/ # Windows Installer files from build outputs *.cab *.msi *.msix *.msm *.msp # JetBrains Rider *.sln.iml # macOS .DS_Store .idea/ ================================================ FILE: .vscode/settings.json ================================================ { "dotnet.defaultSolution": "spice.sln" } ================================================ FILE: Directory.Build.props ================================================ false 0.2.0 $(Version)-beta.1 Jonathan Peppers https://github.com/jonathanpeppers/spice .NET C# MAUI Mobile Android iOS LICENSE README.md icon.png $(MSBuildThisFileDirectory)bin/$(Configuration)/ true false ================================================ FILE: Directory.Build.targets ================================================ false ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Jonathan Peppers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: NuGet.config ================================================ ================================================ FILE: README.md ================================================ # Spice 🌶, a spicy cross-platform UI framework! A prototype (and design) of API minimalism for mobile. If you like this idea, star for approval! Read on for details! ![Spice running on iOS and Android](docs/spice.png) ## Getting Started Simply install the template: ```sh dotnet new install Spice.Templates ``` Create either a plain Spice project, or a hybrid "Spice+Blazor" project: ```sh dotnet new spice # Or if you want hybrid/web support dotnet new spice-blazor ``` Or use the project template in Visual Studio: ![Screenshot of the Spice project template in Visual Studio](docs/vs-template.png) Build it as you would for other .NET MAUI projects: ```sh dotnet build # To run on Android dotnet build -f net10.0-android -t:Run # To run on iOS dotnet build -f net10.0-ios -t:Run ``` Of course, you can also just open the project in Visual Studio and hit F5. ## Startup Time & App Size In comparison to a `dotnet new maui` project, I created a Spice project with the same layouts and optimized settings for both project types. (`AndroidLinkMode=r8`, etc.) App size of a single-architecture `.apk`, built for `android-arm64`: ![Graph of an app size comparison](docs/appsize.png) The average startup time of 10 runs on a Pixel 5: ![Graph of a startup comparison](docs/startup.png) This gives you an idea of how much "stuff" is in .NET MAUI. In some respects the above comparison isn't completely fair, as Spice 🌶 has very few features. However, Spice 🌶 is [fully trimmable][trimming], and so a `Release` build of an app without `Spice.Button` will have the code for `Spice.Button` trimmed away. It will be quite difficult for .NET MAUI to become [fully trimmable][trimming] -- due to the nature of XAML, data-binding, and other System.Reflection usage in the framework. [trimming]: https://learn.microsoft.com/dotnet/core/deploying/trimming/prepare-libraries-for-trimming ## Background & Motivation In reviewing, many of the *cool* UI frameworks for mobile: * [Flutter](https://flutter.dev) * [SwiftUI](https://developer.apple.com/xcode/swiftui/) * [Jetpack Compose](https://developer.android.com/jetpack/compose) * [Fabulous](https://fabulous.dev/) * [Comet](https://github.com/dotnet/Comet) * An, of course, [.NET MAUI](https://dotnet.microsoft.com/apps/maui)! Looking at what apps look like today -- it seems like bunch of rigamarole to me. Can we build mobile applications *without* design patterns? The idea is we could build apps in a simple way, in a similar vein as [minimal APIs in ASP.NET Core][minimal-apis] but for mobile & maybe one day desktop: ```csharp public class App : Application { public App() { int count = 0; var label = new Label { Text = "Hello, Spice 🌶", }; var button = new Button { Text = "Click Me", Clicked = _ => label.Text = $"Times: {++count}" }; Main = new StackLayout { label, button }; } } ``` These "view" types are mostly just [POCOs][poco]. Thus you can easily write unit tests in a vanilla `net10.0` Xunit project, such as: ```csharp [Fact] public void Application() { var app = new App(); var label = (Label)app.Main.Children[0]; var button = (Button)app.Main.Children[1]; button.Clicked(button); Assert.Equal("Times: 1", label.Text); button.Clicked(button); Assert.Equal("Times: 2", label.Text); } ``` The above views in a `net10.0` project are not real UI, while `net10.0-android` and `net10.0-ios` projects get the full implementations that actually *do* something on screen. So for example, adding `App` to the screen on Android: ```csharp protected override void OnCreate(Bundle? savedInstanceState) { base.OnCreate(savedInstanceState); SetContentView(new App()); } ``` And on iOS: ```csharp var vc = new UIViewController(); vc.View.AddSubview(new App()); Window.RootViewController = vc; ``` `App` is a native view on both platforms. You just add it to an the screen as you would any other control or view. This can be mix & matched with regular iOS & Android UI because Spice 🌶 views are just native views. [poco]: https://en.wikipedia.org/wiki/Plain_old_CLR_object [minimal-apis]: https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis ## *NEW* Blazor Support Currently, Blazor/Hybrid apps are strongly tied to .NET MAUI. The implementation is basically working with the plumbing of the native "web view" on each platform. So we could have implemented `BlazorWebView` to be used in "plain" `dotnet new android` or `dotnet new ios` apps. For now, I've migrated some of the source code from `BlazorWebView` from .NET MAUI to Spice 🌶, making it available as a new control: ```csharp public class App : Application { public App() { Main = new BlazorWebView { HostPage = "wwwroot/index.html", RootComponents = { new RootComponent { Selector = "#app", ComponentType = typeof(Main) } }, }; } } ``` From here, you can write `Index.razor` as the Blazor you know and love: ```razor @page "/"

Hello, world!

Welcome to your new app. ``` To arrive at Blazor web content inside iOS/Android apps: ![Screenshot of Blazor app on iOS](docs/blazor.png) This setup might be particularly useful if you want web content to take full control of the screen with minimal native controls. No need for the app size / startup overhead of .NET MAUI if you don't actually have native content? ## Scope * No XAML. No DI. No MVVM. No MVC. No data-binding. No System.Reflection. * *Do we need these things?* * Target iOS & Android only to start. * Implement only the simplest controls. * The native platforms do their own layout. * Document how to author custom controls. * Leverage C# Hot Reload for fast development. * Measure startup time & app size. * Profit? Benefits of this approach are full support for [trimming][trimming] and eventually [NativeAOT][nativeaot] if it comes to mobile one day. 😉 [nativeaot]: https://learn.microsoft.com/dotnet/core/deploying/native-aot/ ## Thoughts on .NET MAUI .NET MAUI is great. XAML is great. Think of this idea as a "mini" MAUI. Spice 🌶 will even leverage various parts of .NET MAUI: * The iOS and Android workloads for .NET. * The .NET MAUI "Single Project" system. * The .NET MAUI "Asset" system, aka Resizetizer. * Microsoft.Maui.Graphics for primitives like `Color`. And, of course, you should be able to use Microsoft.Maui.Essentials by opting in with `UseMauiEssentials=true`. It is an achievement in itself that I was able to invent my own UI framework and pick and choose the pieces of .NET MAUI that made sense for my framework. ## Implemented Controls * `View`: maps to `Android.Views.View` and `UIKit.View`. * `Label`: maps to `Android.Widget.TextView` and `UIKit.UILabel` * `Button`: maps to `Android.Widget.Button` and `UIKit.UIButton` * `StackLayout`: maps to `Android.Widget.LinearLayout` and `UIKit.UIStackView` * `Image`: maps to `Android.Widget.ImageView` and `UIKit.UIImageView` * `Entry`: maps to `Android.Widget.EditText` and `UIKit.UITextField` * `WebView`: maps to `Android.Webkit.WebView` and `WebKit.WKWebView` * `BlazorWebView` extends `WebView` adding support for Blazor. Use the `spice-blazor` template to get started. ## Custom Controls Let's review an implementation for `Image`. First, you can write the cross-platform part for a vanilla `net10.0` class library: ```csharp public partial class Image : View { [ObservableProperty] string _source = ""; } ``` `[ObservableProperty]` comes from the [MVVM Community Toolkit][observable] -- I made use of it for simplicity. It will automatically generate various `partial` methods, `INotifyPropertyChanged`, and a `public` property named `Source`. We can implement the control on Android, such as: ```csharp public partial class Image { public static implicit operator ImageView(Image image) => image.NativeView; public Image() : base(c => new ImageView(c)) { } public new ImageView NativeView => (ImageView)_nativeView.Value; partial void OnSourceChanged(string value) { // NOTE: the real implementation is in Java for performance reasons var image = NativeView; var context = image.Context; int id = context!.Resources!.GetIdentifier(value, "drawable", context.PackageName); if (id != 0) { image.SetImageResource(id); } } } ``` This code takes the name of an image, and looks up a drawable with the same name. This also leverages the .NET MAUI asset system, so a `spice.svg` can simply be loaded via `new Image { Source = "spice" }`. Lastly, the iOS implementation: ```csharp public partial class Image { public static implicit operator UIImageView(Image image) => image.NativeView; public Image() : base(_ => new UIImageView { AutoresizingMask = UIViewAutoresizing.None }) { } public new UIImageView NativeView => (UIImageView)_nativeView.Value; partial void OnSourceChanged(string value) => NativeView.Image = UIImage.FromFile($"{value}.png"); } ``` This implementation is a bit simpler, all we have to do is call `UIImage.FromFile()` and make sure to append a `.png` file extension that the MAUI asset system generates. Now, let's say you don't want to create a control from scratch. Imagine a "ghost button": ```csharp class GhostButton : Button { public GhostButton() => NativeView.Alpha = 0.5f; } ``` In this case, the `NativeView` property returns the underlying `Android.Widget.Button` or `UIKit.Button` that both conveniently have an `Alpha` property that ranges from 0.0f to 1.0f. The same code works on both platforms! Imagine the APIs were different, you could instead do: ```csharp class GhostButton : Button { public GhostButton { #if ANDROID NativeView.SomeAndroidAPI(0.5f); #elif IOS NativeView.SomeiOSAPI(0.5f); #endif } } ``` Accessing the native views don't require any weird design patterns. Just `#if` as you please. [observable]: https://learn.microsoft.com/dotnet/communitytoolkit/mvvm/generators/observableproperty ## Hot Reload C# Hot Reload (in Visual Studio) works fine, as it does for vanilla .NET iOS/Android apps: ![Hot Reload Demo](docs/hotreload.gif) Note that this only works for `Button.Clicked` because the method is invoked when you click. If the method that was changed was already run, *something* has to force it to run again. [`MetadataUpdateHandler`][muh] is the solution to this problem, giving frameworks a way to "reload themselves" for Hot Reload. Unfortunately, [`MetadataUpdateHandler`][muh] does not currently work for non-MAUI apps in Visual Studio 2022 17.5: ```csharp [assembly: System.Reflection.Metadata.MetadataUpdateHandler(typeof(HotReload))] static class HotReload { static void UpdateApplication(Type[]? updatedTypes) { if (updatedTypes == null) return; foreach (var type in updatedTypes) { // Do something with the type Console.WriteLine("UpdateApplication: " + type); } } } ``` The above code works fine in a `dotnet new maui` app, but not a `dotnet new spice` or `dotnet new android` application. And so we can't add proper functionality for reloading `ctor`'s of Spice 🌶 views. The general idea is we could recreate the `App` class and replace the views on screen. We could also create Android activities or iOS view controllers if necessary. Hopefully, we can implement this for a future release of Visual Studio. [muh]: https://learn.microsoft.com/dotnet/api/system.reflection.metadata.metadataupdatehandlerattribute ================================================ FILE: docs/DATA-BINDING-SPEC.md ================================================ # Data-Binding in Spice 🌶 **Status:** Design Specification **Created:** February 2026 ## Overview Spice views already extend `ObservableObject` (via CommunityToolkit.Mvvm), so every `View`, `Label`, `CheckBox`, etc. implements `INotifyPropertyChanged` out of the box. This spec proposes a small `Bind()` helper that wires `PropertyChanged` subscriptions with less boilerplate — while staying NativeAOT safe, trimmer safe, and reflection-free. ## What We Have Today Direct lambda assignments — explicit, transparent, zero magic: ```csharp public class App : Application { public App() { int count = 0; var label = new Label { Text = "Hello, Spice 🌶" }; var button = new Button { Clicked = _ => label.Text = $"Times: {++count}" }; Main = new StackLayout { label, button }; } } ``` For larger UIs, a ViewModel with manual `PropertyChanged` wiring works but gets verbose: ```csharp var vm = new TodoViewModel(); var titleLabel = new Label(); var countLabel = new Label(); var checkbox = new CheckBox(); vm.PropertyChanged += (s, e) => { if (e.PropertyName == nameof(TodoViewModel.Title)) titleLabel.Text = vm.Title; else if (e.PropertyName == nameof(TodoViewModel.Count)) countLabel.Text = $"Count: {vm.Count}"; else if (e.PropertyName == nameof(TodoViewModel.IsCompleted)) checkbox.IsChecked = vm.IsCompleted; }; // Reverse: view → viewmodel (Action property, not an event) checkbox.CheckedChanged = cb => vm.IsCompleted = cb.IsChecked; Main = new StackLayout { titleLabel, countLabel, checkbox }; ``` ## Proposed: `Bind()` Helper ### Core API One method, no expression trees, no reflection: ```csharp namespace Spice; public static class BindingExtensions { /// /// One-way binding: subscribes to source.PropertyChanged and invokes /// whenever the named property changes. /// Sets the initial value immediately. /// public static IDisposable Bind( this TSource source, string propertyName, Func getter, Action apply) where TSource : INotifyPropertyChanged { // Sync initial value apply(getter(source)); PropertyChangedEventHandler handler = (_, e) => { if (e.PropertyName == propertyName) apply(getter(source)); }; source.PropertyChanged += handler; return new BindingSubscription(source, handler); } } internal sealed class BindingSubscription : IDisposable { private readonly INotifyPropertyChanged _source; private readonly PropertyChangedEventHandler _handler; private bool _disposed; public BindingSubscription(INotifyPropertyChanged source, PropertyChangedEventHandler handler) { _source = source; _handler = handler; } public void Dispose() { if (!_disposed) { _source.PropertyChanged -= _handler; _disposed = true; } } } ``` Usage with `nameof()` keeps everything compile-time safe: ```csharp vm.Bind(nameof(vm.Title), v => v.Title, text => titleLabel.Text = text); vm.Bind(nameof(vm.Count), v => v.Count, n => countLabel.Text = $"Count: {n}"); ``` ### Why Not `Expression>`? An expression-tree overload like `vm.Bind(x => x.Title, ...)` is ergonomically nicer, but `Expression.Compile()` internally uses `Reflection.Emit` (or an interpreter on .NET 8+). For a library that promises NativeAOT safety, introducing expression trees is a real trade-off: - ✅ Works on .NET 8+ NativeAOT via the built-in interpreter - ⚠️ Slower than a plain `Func<>` (interpreter overhead + one-time compile cost) - ⚠️ Adds `System.Linq.Expressions` as a de facto dependency - ❌ Fails on older NativeAOT runtimes without the interpreter If the convenience is deemed worth it, an **optional** overload can be added alongside the `nameof` version — never as the only option: ```csharp // Optional convenience overload (expression tree) public static IDisposable Bind( this TSource source, Expression> propertySelector, Action apply) where TSource : INotifyPropertyChanged { string name = ((MemberExpression)propertySelector.Body).Member.Name; var getter = propertySelector.Compile(); return source.Bind(name, getter, apply); } ``` ### Two-Way Binding Since Spice views **already implement `INotifyPropertyChanged`**, true two-way binding is straightforward — subscribe in both directions with a guard to prevent infinite loops: ```csharp public static IDisposable BindTwoWay( this TSource source, string sourceProperty, Func sourceGetter, Action sourceSetter, INotifyPropertyChanged target, string targetProperty, Func targetGetter, Action targetSetter) where TSource : INotifyPropertyChanged { bool updating = false; targetSetter(sourceGetter(source)); // initial sync PropertyChangedEventHandler sourceHandler = (_, e) => { if (!updating && e.PropertyName == sourceProperty) { updating = true; targetSetter(sourceGetter(source)); updating = false; } }; PropertyChangedEventHandler targetHandler = (_, e) => { if (!updating && e.PropertyName == targetProperty) { updating = true; sourceSetter(source, targetGetter()); updating = false; } }; source.PropertyChanged += sourceHandler; target.PropertyChanged += targetHandler; return new TwoWayBindingSubscription(source, sourceHandler, target, targetHandler); } ``` Usage — ViewModel ↔ Entry (both are ObservableObjects): ```csharp vm.BindTwoWay( nameof(vm.Username), v => v.Username, (v, val) => v.Username = val, usernameEntry, nameof(Entry.Text), () => usernameEntry.Text, val => usernameEntry.Text = val); ``` This is verbose, which is intentional — it makes data flow explicit. For common cases, convenience overloads or a fluent builder could be added later. ## Usage Examples ### Simple One-Way ```csharp public class CounterApp : Application { public CounterApp() { var vm = new CounterViewModel(); var label = new Label(); var button = new Button { Text = "Increment", Clicked = _ => vm.Count++ }; vm.Bind(nameof(vm.Count), v => v.Count, n => label.Text = $"Count: {n}"); Main = new StackLayout { label, button }; } } ``` ### CollectionView with Per-Item Binding For CollectionView recycling, wrap the template in a custom `IDisposable` View: ```csharp public class TodoItemView : StackLayout, IDisposable { private IDisposable? _titleBinding; public TodoItemView(TodoItem item) { var label = new Label(); var checkbox = new CheckBox(); // One-way: model → view _titleBinding = item.Bind(nameof(item.Title), t => t.Title, text => label.Text = text); item.Bind(nameof(item.IsCompleted), t => t.IsCompleted, done => checkbox.IsChecked = done); // Reverse: view → model checkbox.CheckedChanged = cb => item.IsCompleted = cb.IsChecked; Children.Add(new StackLayout { label, checkbox }); } public void Dispose() { _titleBinding?.Dispose(); } } var collectionView = new CollectionView { ItemsSource = vm.Items, ItemTemplate = item => new TodoItemView((TodoItem)item) }; ``` When the view is recycled, `Dispose()` will be called and subscriptions cleaned up. See [VIEW-LIFECYCLE-SPEC.md](./VIEW-LIFECYCLE-SPEC.md) for details. ## Memory Management `Bind()` returns `IDisposable`. In most Spice apps, bindings live as long as the view, so disposal is unnecessary. For short-lived views bound to long-lived sources, dispose explicitly: ```csharp var binding = vm.Bind(nameof(vm.Title), v => v.Title, text => label.Text = text); // ... binding.Dispose(); // unsubscribes from PropertyChanged ``` Multiple bindings can be grouped with `CompositeDisposable` or a simple list: ```csharp var bindings = new List { vm.Bind(nameof(vm.Title), v => v.Title, t => label.Text = t), vm.Bind(nameof(vm.Count), v => v.Count, n => countLabel.Text = $"{n}"), }; // Dispose all at once foreach (var b in bindings) b.Dispose(); ``` ## Alternatives Considered | Approach | Pros | Cons | |---|---|---| | **Manual `PropertyChanged` (status quo)** | Zero abstraction, fully explicit | Verbose, error-prone string matching | | **`nameof` + `Func<>` (this proposal)** | NativeAOT safe, no new dependencies | Property name repeated in `nameof()` call | | **`Expression>` only** | Ergonomic (`x => x.Title`) | `Expression.Compile()` has NativeAOT caveats | | **Source generator** | Zero runtime cost, best ergonomics | Significant implementation effort, new tooling | | **CallerArgumentExpression hack** | No expression trees | Fragile string parsing, breaks with refactoring | The `nameof` + `Func<>` approach is the right default for Spice: zero new dependencies, truly NativeAOT safe, and the `nameof()` repetition is a minor cost for full transparency. ## Future Work 1. **View lifecycle** — Add `Detached` event to `View` for CollectionView recycling cleanup 2. **Source generator** — Compile-time codegen to eliminate `nameof()` boilerplate 3. **Value converters** — `Bind(..., converter: boolToColor)` for transformations ## Summary - Spice views already implement `INotifyPropertyChanged` — leverage it - `Bind()` is a thin helper over `PropertyChanged`, not a framework - `nameof()` + `Func<>` = truly NativeAOT safe, no expression trees required - Two-way binding works because both sides are `ObservableObject` - Expression-tree overload is opt-in, not the default ================================================ FILE: docs/MAUI-CONTROLS-COMPARISON.md ================================================ # .NET MAUI Controls vs Spice Implementation Status This document compares the stable/supported controls from .NET MAUI with what is currently implemented in Spice. ## Pages | MAUI Control | Implemented in Spice | Should Implement? | Notes | |--------------|---------------------|-------------------|-------| | ContentPage | ❌ No | ❌ No | Spice uses a different architecture without MAUI Controls | | FlyoutPage | ❌ No | ❌ No | Spice uses a different architecture without MAUI Controls | | NavigationPage | ❌ No | ❌ No | Spice uses a different architecture without MAUI Controls | | TabbedPage | ❌ No | ❌ No | Spice uses a different architecture without MAUI Controls | ## Layouts | MAUI Control | Implemented in Spice | Should Implement? | Notes | |--------------|---------------------|-------------------|-------| | AbsoluteLayout | ❌ No | ❌ No | Rare use case, complex | | BindableLayout | ❌ No | ❌ No | Binding-focused pattern | | FlexLayout | ❌ No | 🟡 Maybe | Powerful but complex CSS flexbox | | Grid | ✅ Yes | ✅ Done | Essential for complex layouts | | HorizontalStackLayout | ❌ No | ❌ No | StackLayout with Horizontal orientation | | StackLayout | ✅ Yes | ✅ Done | Fully implemented | | VerticalStackLayout | ❌ No | ❌ No | StackLayout with Vertical orientation | ## Views | MAUI Control | Implemented in Spice | Should Implement? | Notes | |--------------|---------------------|-------------------|-------| | ActivityIndicator | ✅ Yes | ✅ Done | Loading spinner - very common | | BlazorWebView | ✅ Yes | ✅ Done | Extends `WebView` in Blazor/ folders | | Border | ✅ Yes | ✅ Done | Useful for rounded corners/borders | | BoxView | ✅ Yes | ✅ Done | Colored rectangles - useful for dividers | | Button | ✅ Yes | ✅ Done | Fully implemented | | CarouselView | ❌ No | ❌ No | Complex, less common | | CheckBox | ✅ Yes | ✅ Done | Standard checkbox input | | CollectionView | ✅ Yes | ✅ Done | Powerful grid/list control | | ContentView | ✅ Yes | ✅ Done | Custom control composition | | DatePicker | ✅ Yes | ✅ Done | Date selection - common in forms | | Editor | ✅ Yes | ✅ Done | Multi-line text input | | Ellipse | ❌ No | 🟢 Maybe | Shape control - can use Image | | Entry | ✅ Yes | ✅ Done | Single-line text input | | Frame | ❌ No | ❌ No | Superseded by Border | | GraphicsView | ❌ No | ❌ No | Advanced - Microsoft.Maui.Graphics available | | HybridWebView | ❌ No | ❌ No | Specialized, newer control | | Image | ✅ Yes | ✅ Done | Fully implemented | | ImageButton | ✅ Yes | ✅ Done | Common pattern (Image + tap) | | IndicatorView | ❌ No | ❌ No | Depends on CarouselView | | Label | ✅ Yes | ✅ Done | Fully implemented | | Line | ❌ No | ❌ No | Shape control - can use BoxView | | ListView | ❌ No | 🟡 Yes | Scrollable lists - very common | | Map | ❌ No | ❌ No | External dependency | | Path | ❌ No | ❌ No | Complex shapes - can use Image | | Picker | ✅ Yes | ✅ Done | Dropdown selection - essential | | Polygon | ❌ No | ❌ No | Shape control - can use Image | | Polyline | ❌ No | ❌ No | Shape control - can use Image | | ProgressBar | ✅ Yes | ✅ Done | Progress display - common | | RadioButton | ✅ Yes | ✅ Done | Single selection from a group; uses cross-platform GroupName (no Android RadioGroup) because iOS lacks a native radio button | | Rectangle | ❌ No | 🟢 Maybe | Shape control - BoxView covers this | | RefreshView | ✅ Yes | ✅ Done | Pull-to-refresh wrapper | | RoundRectangle | ❌ No | ❌ No | Border can handle this | | ScrollView | ✅ Yes | ✅ Done | Fully implemented | | SearchBar | ✅ Yes | ✅ Done | Search input with search button | | Slider | ✅ Yes | ✅ Done | Range selection - common | | Stepper | ❌ No | ❌ No | Rare, can use buttons + label | | SwipeView | ✅ Yes | ✅ Done | Swipe actions - nice UX feature | | Switch | ✅ Yes | ✅ Done | Toggle control - essential | | TableView | ❌ No | ❌ No | Settings-style list (less common) | | TimePicker | ✅ Yes | ✅ Done | Time selection - common in forms | | TitleBar | ❌ No | ❌ No | Desktop-focused | | TwoPaneView | ❌ No | ❌ No | Foldable-specific | | WebView | ✅ Yes | ✅ Done | Fully implemented | ## Summary **Implemented: 26 / 60+ controls** ### Spice Controls (Core) - ✅ ActivityIndicator - ✅ Application - ✅ Border - ✅ BoxView - ✅ Button - ✅ CheckBox - ✅ CollectionView - ✅ ContentView - ✅ DatePicker - ✅ Editor (multi-line text) - ✅ Entry (single-line text) - ✅ Grid - ✅ Image - ✅ ImageButton - ✅ Label - ✅ Picker - ✅ ProgressBar - ✅ RadioButton - ✅ RefreshView - ✅ ScrollView - ✅ SearchBar - ✅ Slider - ✅ StackLayout - ✅ SwipeView - ✅ Switch (toggle control) - ✅ TimePicker (time selection) - ✅ View (base class) - ✅ WebView - ✅ BlazorWebView (Blazor-specific) ### Supporting Types - LayoutAlignment (enums for alignment) - LayoutOptions (alignment with expansion) - Orientation (horizontal/vertical) - RootComponent (Blazor) - SelectionMode (selection in lists) - SwipeBehaviorOnInvoked, SwipeDirection, SwipeItem, SwipeItems, SwipeMode (swipe gesture support) ### Key Differences - **No XAML**: Spice uses POCOs, not XAML markup - **No Data Binding**: No `System.Reflection` or binding infrastructure - **No MVVM**: Direct code, no ViewModels required - **Partial Class Pattern**: Each control has cross-platform Core + iOS + Android partials - **Minimal Dependencies**: Only uses `Microsoft.Maui.Graphics` (Color) and MAUI's SingleProject ### Platform Mappings #### iOS (UIKit) - ActivityIndicator → UIActivityIndicatorView - Border → UIView (with CALayer border) - BoxView → UIView - Button → UIButton - CheckBox → UIButton (with checkmark styling) - CollectionView → UICollectionView - ContentView → UIView - DatePicker → UIDatePicker - Editor → UITextView - Entry → UITextField - Grid → Custom constraint-based layout - Image → UIImageView - ImageButton → UIButton - Label → UILabel - Picker → UIPickerView - ProgressBar → UIProgressView - RadioButton → UIButton (with circle/circle.fill SF Symbols; cross-platform GroupName for exclusivity) - RefreshView → UIView with UIRefreshControl - ScrollView → UIScrollView - SearchBar → UISearchBar - Slider → UISlider - StackLayout → UIStackView - SwipeView → UIView with gesture recognizers - Switch → UISwitch - TimePicker → UIDatePicker (Mode = Time) - WebView → WKWebView #### Android (Android Widgets) - ActivityIndicator → ProgressBar (indeterminate) - Border → FrameLayout (with GradientDrawable background) - BoxView → View - Button → AppCompatButton - CheckBox → CheckBox - CollectionView → AndroidX.RecyclerView.Widget.RecyclerView - ContentView → FrameLayout - DatePicker → DatePickerDialog - Editor → EditText (multiline) - Entry → AppCompatEditText - Grid → GridLayout - Image → AppCompatImageView - ImageButton → ImageButton - Label → AppCompatTextView - Picker → Spinner - ProgressBar → ProgressBar - RadioButton → Android.Widget.RadioButton (cross-platform GroupName for exclusivity, not RadioGroup) - RefreshView → AndroidX.SwipeRefreshLayout.Widget.SwipeRefreshLayout - ScrollView → ScrollView / HorizontalScrollView - SearchBar → SearchView - Slider → SeekBar - StackLayout → LinearLayout - SwipeView → Custom view with gesture detection - Switch → SwitchCompat - TimePicker → TimePickerDialog - WebView → WebView --- ## MAUI View/VisualElement Properties vs Spice View This section compares the properties available on MAUI's `View` class (which inherits from `VisualElement`, `NavigableElement`, `Element`, and `BindableObject`) with Spice's `View` base class. ### Layout & Sizing Properties | MAUI Property | Spice Implementation | Should Implement? | Notes | |--------------|---------------------|-------------------|-------| | Width | ✅ Yes | ✅ Done | Read-only - returns actual rendered width | | Height | ✅ Yes | ✅ Done | Read-only - returns actual rendered height | | WidthRequest | ✅ Yes | ✅ Done | Desired width - essential for sizing | | HeightRequest | ✅ Yes | ✅ Done | Desired height - essential for sizing | | MinimumWidthRequest | ❌ No | 🟡 Maybe | Useful for responsive layouts | | MinimumHeightRequest | ❌ No | 🟡 Maybe | Useful for responsive layouts | | MaximumWidthRequest | ❌ No | 🟡 Maybe | Useful for responsive layouts | | MaximumHeightRequest | ❌ No | 🟡 Maybe | Useful for responsive layouts | | HorizontalOptions | ✅ Yes | ✅ Done | Spice: `HorizontalOptions` (LayoutOptions) | | VerticalOptions | ✅ Yes | ✅ Done | Spice: `VerticalOptions` (LayoutOptions) | | Margin | ✅ Yes | ✅ Done | Outer spacing using Thickness struct | | Bounds | ❌ No | ❌ No | Read-only - internal layout info | | Frame | ❌ No | ❌ No | Read-only - screen position | | DesiredSize | ❌ No | ❌ No | Read-only - layout system internal | ### Alignment Properties | MAUI Property | Spice Implementation | Should Implement? | Notes | |--------------|---------------------|-------------------|-------| | HorizontalOptions (MAUI) | ✅ Yes | ✅ Done | Spice: `HorizontalOptions` (LayoutOptions) | | VerticalOptions (MAUI) | ✅ Yes | ✅ Done | Spice: `VerticalOptions` (LayoutOptions) | ### Appearance Properties | MAUI Property | Spice Implementation | Should Implement? | Notes | |--------------|---------------------|-------------------|-------| | BackgroundColor | ✅ Yes | ✅ Done | Color type | | Background | ❌ No | ❌ No | Brush (gradients) - complex | | Opacity | ✅ Yes | ✅ Done | 0-1 transparency - clamped range | | IsVisible | ✅ Yes | ✅ Done | Show/hide element - very common | | Shadow | ❌ No | ❌ No | Platform-inconsistent, use native | | Clip | ❌ No | ❌ No | Advanced, less common | ### Transform Properties | MAUI Property | Spice Implementation | Should Implement? | Notes | |--------------|---------------------|-------------------|-------| | Rotation | ❌ No | ❌ No | Animation-focused, adds complexity | | RotationX | ❌ No | ❌ No | 3D transforms - rare use case | | RotationY | ❌ No | ❌ No | 3D transforms - rare use case | | Scale | ❌ No | ❌ No | Animation-focused | | ScaleX | ❌ No | ❌ No | Animation-focused | | ScaleY | ❌ No | ❌ No | Animation-focused | | TranslationX | ❌ No | ❌ No | Animation-focused | | TranslationY | ❌ No | ❌ No | Animation-focused | | AnchorX | ❌ No | ❌ No | Transform origin - depends on transforms | | AnchorY | ❌ No | ❌ No | Transform origin - depends on transforms | ### Interaction Properties | MAUI Property | Spice Implementation | Should Implement? | Notes | |--------------|---------------------|-------------------|-------| | IsEnabled | ✅ Yes | ✅ Done | Enable/disable interaction - essential for forms | | InputTransparent | ❌ No | 🟡 Maybe | Pass-through touch events - useful | | IsFocused | ❌ No | ❌ No | Read-only focus state - advanced | | GestureRecognizers | ❌ No | ❌ No | Add tap handlers directly to controls | ### Hierarchy & Navigation | MAUI Property | Spice Implementation | Should Implement? | Notes | |--------------|---------------------|-------------------|-------| | Children (collection) | ✅ Yes | ✅ Done | `ObservableCollection` | | Parent | ❌ No | 🟡 Maybe | Parent element - useful for traversal | | Navigation | ❌ No | ❌ No | MAUI page-based navigation | | Id | ❌ No | ❌ No | Unique identifier - less useful | ### Styling & Resources | MAUI Property | Spice Implementation | Should Implement? | Notes | |--------------|---------------------|-------------------|-------| | Style | ❌ No | ❌ No | XAML-focused pattern | | StyleClass | ❌ No | ❌ No | CSS-like classes - binding-focused | | Class | ❌ No | ❌ No | Style classes - binding-focused | | ClassId | ❌ No | ❌ No | Semantic identifier - testing-focused | | StyleId | ❌ No | ❌ No | User identifier - debugging-focused | | Resources | ❌ No | ❌ No | XAML resource dictionary | | Behaviors | ❌ No | ❌ No | XAML behavior system | | Triggers | ❌ No | ❌ No | XAML property triggers | | Effects | ❌ No | ❌ No | Platform effects - advanced | ### Data Binding & Context | MAUI Property | Spice Implementation | Should Implement? | Notes | |--------------|---------------------|-------------------|-------| | BindingContext | ❌ No | ❌ No | Data binding context (not Spice's philosophy) | ### Platform & Accessibility | MAUI Property | Spice Implementation | Should Implement? | Notes | |--------------|---------------------|-------------------|-------| | AutomationId | ✅ Yes | ✅ Done | UI testing identifier - useful for QA | | Handler | ❌ No | ❌ No | Platform handler - internal | | FlowDirection | ❌ No | 🟢 Maybe | RTL support - i18n feature | | IsLoaded | ❌ No | ❌ No | Loaded state - internal lifecycle | | Dispatcher | ❌ No | ❌ No | UI thread dispatcher - internal | ### Spice-Specific Properties | Spice Property | MAUI Equivalent | Notes | |---------------|-----------------|-------| | Children | Yes (in Container types) | `ObservableCollection`, supports collection initializers | | HorizontalOptions | HorizontalOptions | Uses `LayoutOptions` with alignment and expansion flags | | VerticalOptions | VerticalOptions | Uses `LayoutOptions` with alignment and expansion flags | | BackgroundColor | BackgroundColor | Uses `Microsoft.Maui.Graphics.Color` | | IsVisible | IsVisible | Show/hide element | | IsEnabled | IsEnabled | Enable/disable interaction | | Opacity | Opacity | 0-1 transparency, clamped range | | AutomationId | AutomationId | UI testing identifier | | Margin | Margin | Outer spacing using Thickness struct | | WidthRequest | WidthRequest | Desired width for sizing | | HeightRequest | HeightRequest | Desired height for sizing | | Width | Width | Read-only actual width | | Height | Height | Read-only actual height | ### Summary **Spice View Properties: 14** - Children (collection) - HorizontalOptions (LayoutOptions) - VerticalOptions (LayoutOptions) - BackgroundColor - IsVisible - IsEnabled - Opacity - AutomationId - Margin - WidthRequest - HeightRequest - Width (read-only) - Height (read-only) **MAUI View/VisualElement Properties: 60+** Spice's `View` class is intentionally minimal, focusing on the essential properties needed for basic layout and appearance. MAUI's extensive property set supports: - Complex styling and theming (not in Spice) - Data binding and MVVM (not in Spice) - Advanced transforms and animations (not in Spice) - Accessibility and testing infrastructure (not in Spice) - Resource management and behaviors (not in Spice) Spice uses `[ObservableProperty]` for property change notifications, generating `On{Prop}Changed` partial methods implemented in platform-specific files, rather than MAUI's `BindableProperty` system. --- ## Recommended Additions for Spice Based on Spice's minimalist philosophy and common mobile UI needs, here are reasonable additions that would enhance functionality without compromising simplicity: ### 🔥 High Priority - Essential Controls **Layouts** - ✅ **Grid** - Essential for complex layouts; maps to UIStackView/LinearLayout with weights or constraint-based layout (IMPLEMENTED) - ✅ **ScrollView** - Fundamental for scrollable content; maps to UIScrollView/ScrollView (IMPLEMENTED) **Input Controls** - ✅ **Switch** - Standard toggle control; maps to UISwitch/SwitchCompat (IMPLEMENTED) - ✅ **Slider** - Common for settings/media controls; maps to UISlider/SeekBar (IMPLEMENTED) - ✅ **Picker** - Standard dropdown/selection; maps to UIPickerView/Spinner (IMPLEMENTED) - ✅ **DatePicker** - Date selection; maps to UIDatePicker/DatePickerDialog (IMPLEMENTED) - ✅ **TimePicker** - Time selection; maps to UIDatePicker/TimePickerDialog (IMPLEMENTED) - ✅ **CheckBox** - Boolean selection; maps to UIButton (checkmark)/CheckBox (IMPLEMENTED) **Display Controls** - ✅ **ActivityIndicator** - Loading spinner; maps to UIActivityIndicatorView/ProgressBar (indeterminate) (IMPLEMENTED) - ✅ **ProgressBar** - Progress display; maps to UIProgressView/ProgressBar (determinate) (IMPLEMENTED) ### 🟡 Medium Priority - Very Useful **Layouts** - ✅ **ContentView** - Custom control container for composition (IMPLEMENTED) - ✅ **Border** - Wraps content with border/rounded corners; common UI pattern (IMPLEMENTED) **Lists** - ✅ **CollectionView** - Flexible grid/list; maps to UICollectionView/RecyclerView (IMPLEMENTED) - 🟡 **ListView** - Scrollable list of items; maps to UITableView/RecyclerView (critical for many apps) **Input** - ✅ **Editor** - Multi-line text input; maps to UITextView/EditText (multiline) (IMPLEMENTED) - ✅ **SearchBar** - Search input; maps to UISearchBar/SearchView (IMPLEMENTED) **Display** - ✅ **ImageButton** - Tappable image; common pattern (can be done with Image + gesture) (IMPLEMENTED) ### 🟢 Nice to Have - Special Cases **Advanced Controls** - ✅ **RefreshView** - Pull-to-refresh wrapper (IMPLEMENTED) - ✅ **SwipeView** - Swipe actions/context menus (IMPLEMENTED) - ✅ **RadioButton** - Radio button groups; uses cross-platform GroupName since iOS has no native radio concept **Shapes** (Lower priority - can use Image or GraphicsView) - ✅ **BoxView** - Colored rectangle (useful for dividers/spacers) (IMPLEMENTED) - 🟢 **Rectangle/Ellipse** - Basic shapes ### 📊 View Properties - High Priority **Layout & Sizing** - ✅ **WidthRequest/HeightRequest** - Essential for sizing views (IMPLEMENTED) - ✅ **Margin** - Outer spacing (critical for layouts) (IMPLEMENTED) - 🟡 **Padding** - Inner spacing (for containers) **Appearance** - ✅ **IsVisible** - Show/hide elements (very common) (IMPLEMENTED) - ✅ **Opacity** - Transparency (common for fade effects) (IMPLEMENTED) **Interaction** - ✅ **IsEnabled** - Enable/disable controls (essential for forms) (IMPLEMENTED) ### ❌ Not Recommended **Probably Skip** - ❌ **Transforms** (Rotation, Scale, Translation) - Animation-focused, adds complexity - ❌ **CarouselView** - Complex, less common - ❌ **IndicatorView** - Depends on CarouselView - ❌ **TabbedPage/NavigationPage** - Page-level navigation (different architecture) - ❌ **Map** - External dependency (Microsoft.Maui.Controls.Maps) - ❌ **GraphicsView** - Advanced graphics (Microsoft.Maui.Graphics already available) - ❌ **HybridWebView** - Specialized, newer control - ❌ **TitleBar** - Desktop-focused - ❌ **TwoPaneView** - Foldable-specific - ❌ **Shapes** (Path, Polygon, Polyline, Line) - Can use Image or custom drawing - ❌ **Shadow** - Platform-inconsistent, can use native code - ❌ **Clip** - Advanced, less common - ❌ **GestureRecognizers** - Can add tap handlers directly to controls - ❌ **Behaviors/Triggers/Effects** - XAML/binding-focused patterns - ❌ **Stepper** - Rare, can use buttons + label ### Implementation Priority **Phase 1 (Core Controls)** ✅ 1. ✅ Grid layout 2. ✅ ScrollView 3. ✅ Switch 4. ✅ ActivityIndicator 5. ✅ ProgressBar 6. ✅ IsVisible property 7. ✅ IsEnabled property 8. ✅ WidthRequest/HeightRequest 9. ✅ Margin **Phase 2 (Input Controls)** ✅ 1. ✅ Picker 2. ✅ Slider 3. ✅ CheckBox 4. ✅ DatePicker 5. ✅ TimePicker 6. ✅ Editor (multiline text) **Phase 3 (Lists & Advanced)** ✅ 1. 🟡 ListView 2. ✅ SearchBar 3. ✅ CollectionView 4. ✅ Border 5. ✅ ContentView 6. ✅ ImageButton **Phase 4 (Nice-to-Have)** ✅ 1. ✅ RefreshView 2. ✅ SwipeView 3. ✅ BoxView 4. ✅ RadioButton 5. ✅ Opacity property --- *Note: Spice is focused on minimal cross-platform UI with no Microsoft.Maui.Controls dependency. Controls are added based on common mobile scenarios rather than full MAUI parity.* ================================================ FILE: docs/NAVIGATION-SPEC.md ================================================ # Navigation in Spice 🌶 Three new views and two methods on `Application`. That's it. ## Overview | Type | What it does | iOS | Android | |------|-------------|-----|---------| | `NavigationView` | Push/pop stack with nav bar | `UINavigationController` | AndroidX Navigation | | `TabView` | Bottom tabs | `UITabBarController` | `BottomNavigationView` | | `Application.PresentAsync()` | Modal overlay | `PresentViewController()` | `DialogFragment` | > These are **views**, not pages — they compose with existing Spice views like any other layout. > `Application.Main` swapping still works; these are additive. ## NavigationView ```csharp public class App : Application { public App() { Main = new NavigationView(); } } public class HomeView : StackLayout { public HomeView() { Title = "Home"; Add(new Label { Text = "Welcome!" }); Add(new Button { Text = "Details", Clicked = _ => Navigation!.Push() }); } } public class DetailView : StackLayout { public DetailView() { Title = "Details"; Add(new Label { Text = "Detail content" }); } // Back button is automatic — no code needed } ``` ### Proposed API ```csharp // Added to View base class public partial class View { [ObservableProperty] string _title = ""; public NavigationView? Navigation { get; internal set; } } public partial class NavigationView : View { public NavigationView(View root); public NavigationView(Func factory); // lazy with args public void Push(View view); public void Push(Func factory); // lazy with args public void Push() where T : View, new(); // lazy parameterless public void Pop(); public void PopToRoot(); } // Generic subclass — creates root view lazily public partial class NavigationView : NavigationView where TRoot : View, new() { public NavigationView(); } ``` `Push`/`Pop` are synchronous — matches Spice's `Action` callback pattern. The native platform handles animations. `Push()` and `Push(() => new T(args))` create the view on demand. Use `Push(view)` when you already have one. `Title` is added to `View` so any view can set a nav bar title. Ignored when not inside a `NavigationView`. `Navigation` is set on child views by `NavigationView` when they're pushed, similar to how layouts already manage their `Children`. ## TabView ```csharp Main = new TabView { new Tab("Home", "home.png"), new Tab("Search", "search.png"), new Tab("Profile", "profile.png"), }; ``` Each tab can contain a `NavigationView` for independent push/pop stacks: ```csharp Main = new TabView { new Tab("Home", "home.png", new NavigationView()), new Tab("Search", "search.png", new NavigationView()), }; ``` ### Proposed API ```csharp public partial class TabView : View { public void Add(Tab tab); // collection initializer support } public partial class Tab : View { [ObservableProperty] string _icon = ""; public Tab(string title, string icon, View content); public Tab(string title, string icon, Func factory); // lazy with args } // Generic subclass — creates content lazily on first tab selection public partial class Tab : Tab where TContent : View, new() { public Tab(string title, string icon); } ``` `Tab` reuses `View.Title` for the tab label and `View.Children` for its content. ## Modal Presentation ```csharp // Present (from any view) await PresentAsync(); // Dismiss (from inside the modal) await DismissAsync(); ``` ### Proposed API ```csharp public partial class View { public Task PresentAsync(View view); public Task PresentAsync(Func factory); // lazy with args public Task PresentAsync() where T : View, new(); // lazy parameterless public Task DismissAsync(); } ``` Modal methods live on `View` so any view can present/dismiss — no need for a global `Application.Current` singleton. Modals are async because the caller often needs to know when presentation/dismissal completes (e.g., to read a result). `Push`/`Pop` don't need this — you fire and forget. ## Complete Example ```csharp public class App : Application { public App() { Main = new TabView { new Tab("Feed", "feed.png", new NavigationView()), new Tab("Profile", "profile.png"), }; } } public class FeedView : StackLayout { public FeedView() { Title = "Feed"; Add(new Button { Text = "View Post", Clicked = _ => Navigation!.Push() }); Add(new Button { Text = "New Post", Clicked = async _ => await PresentAsync() }); } } public class PostView : StackLayout { public PostView() { Title = "Post"; Add(new Label { Text = "Post content..." }); Add(new Button { Text = "Comments", Clicked = _ => Navigation!.Push() }); } } public class NewPostView : StackLayout { public NewPostView() { Title = "New Post"; Add(new Entry { Placeholder = "What's on your mind?" }); Add(new Button { Text = "Post", Clicked = async _ => await DismissAsync() }); } } ``` ## Migration ```csharp // Before — swap the whole view tree new Button { Clicked = _ => Main = new DetailView() } // After — push onto the stack, get a nav bar + back button for free new Button { Clicked = _ => Navigation!.Push() } ``` ## Platform Mapping ### iOS | Spice | iOS | |-------|-----| | `NavigationView` | `UINavigationController` | | `Push()` | `pushViewController(_:animated:)` | | `Pop()` | `popViewController(animated:)` | | `PopToRoot()` | `popToRootViewController(animated:)` | | `TabView` | `UITabBarController` | | `Tab` | `UITab` / tab bar item | | `PresentAsync()` | `present(_:animated:completion:)` | | `DismissAsync()` | `dismiss(animated:completion:)` | ### Android | Spice | Android | |-------|---------| | `NavigationView` | `NavController` + `NavHostFragment` | | `Push()` | `navigate()` | | `Pop()` | `navigateUp()` | | `PopToRoot()` | `popBackStack(startDest)` | | `TabView` | `BottomNavigationView` | | `Tab` | Menu item | | `PresentAsync()` | `DialogFragment.show()` | | `DismissAsync()` | `DialogFragment.dismiss()` | ## Design Decisions **Why "views" not "pages"?** Spice doesn't have pages. `NavigationView` and `TabView` are views — they inherit from `View`, use `[ObservableProperty]`, and compose like `StackLayout` or `Grid`. **Why sync Push/Pop but async modals?** Push/pop match Spice's `Action` event pattern — no `async void` needed in `Clicked` handlers. Modals are async because callers often await the result. **Why `Title` on View?** It's one `[ObservableProperty]` field. Ignored unless the view is inside a `NavigationView` or `Tab`. Simpler than a separate "page" abstraction. **What about `Application.Main` swapping?** Still works. Use `NavigationView`/`TabView` when you want native navigation chrome (back button, nav bar, tab bar). Use `Main =` when you don't. **Why three overload forms?** `Push(view)` for pre-built views. `Push()` for parameterless construction. `Push(() => new DetailView(postId))` for lazy creation with arguments. Same pattern on `Tab`, `NavigationView`, and `PresentAsync`. ================================================ FILE: docs/THEME-SPEC.md ================================================ # Themes in Spice 🌶 **Status:** Implemented **Created:** February 2026 ## Overview Spice provides a built-in `Theme` class — a plain C# object with well-known color properties — and a mechanism for views to consume those colors automatically, update live when the theme changes, and still allow per-view overrides. The entire design is **NativeAOT safe, trimmer safe, and reflection-free**. ## `Theme` Class ### Core API A `Theme` is just a POCO that extends `ObservableObject` — same base as every Spice view. It defines **semantic color slots** that map to view properties: ```csharp public partial class Theme : ObservableObject { /// Default text color for Label, Button, Entry, SearchBar, etc. [ObservableProperty] Color? _textColor; /// Default background color for all views. [ObservableProperty] Color? _backgroundColor; /// Accent/tint color for interactive controls (Button background, Switch tint, ActivityIndicator, etc.) [ObservableProperty] Color? _accentColor; /// Border/stroke color for Border views. [ObservableProperty] Color? _strokeColor; /// Placeholder text color for Editor, SearchBar, etc. [ObservableProperty] Color? _placeholderColor; } ``` No reflection, no dictionaries, no string lookups. Just typed properties on a typed object. ### Built-In Light and Dark Themes The built-in themes use float constructors instead of `Colors` static properties to avoid pulling `Microsoft.Maui.Graphics` references into the AOT-compiled output, keeping APK size down: ```csharp public partial class Theme { public static Theme Light => new() { TextColor = Black, // #000000 BackgroundColor = White, // #FFFFFF AccentColor = new Color(0f, 0.471f, 0.831f), // #0078D4 StrokeColor = new Color(0.878f, 0.878f, 0.878f), // #E0E0E0 PlaceholderColor = DarkGray, // #A9A9A9 }; public static Theme Dark => new() { TextColor = White, // #FFFFFF BackgroundColor = new Color(0.118f, 0.118f, 0.118f), // #1E1E1E AccentColor = new Color(0.298f, 0.761f, 1f), // #4CC2FF StrokeColor = new Color(0.251f, 0.251f, 0.251f), // #404040 PlaceholderColor = LightGray, // #D3D3D3 }; } ``` ### Setting the Theme on Application ```csharp public partial class Application : View { /// /// The current theme. Setting this applies colors to the entire view tree /// and subscribes to live updates. Null means no theme — views keep their /// individually-set colors (backward compatible default). /// [ObservableProperty] Theme? _theme; } ``` > **Why nullable?** Theming is opt-in. Existing apps that never set `Theme` continue > working exactly as before — no colors change, no behavior changes. Apps opt in with > a single line: `Theme = Theme.Light;` ## How Theming Works ### Step 1: Each View Knows How to Apply a Theme Every view type overrides a `protected virtual` method that maps theme color slots to its own properties. This is the **only** connection between themes and views — no reflection, no attribute scanning, no magic. The base `View` class applies `BackgroundColor`: ```csharp // In View (base class) protected virtual void ApplyTheme(Theme theme) { if (CanApplyTheme((int)ThemeProperty.BackgroundColor)) BackgroundColor = theme.BackgroundColor; } ``` Subclasses override to add their own mappings: ```csharp // In Label protected override void ApplyTheme(Theme theme) { base.ApplyTheme(theme); if (CanApplyTheme((int)ThemeProperty.TextColor)) TextColor = theme.TextColor; } ``` ```csharp // In Button — uses AccentColor for its background protected override void ApplyTheme(Theme theme) { base.ApplyTheme(theme); if (CanApplyTheme((int)ThemeProperty.TextColor)) TextColor = theme.TextColor; if (CanApplyTheme((int)ThemeProperty.BackgroundColor)) BackgroundColor = theme.AccentColor; } ``` ```csharp // In Border protected override void ApplyTheme(Theme theme) { base.ApplyTheme(theme); if (CanApplyTheme((int)ThemeProperty.Stroke)) Stroke = theme.StrokeColor; } ``` ```csharp // In Editor — text and placeholder colors protected override void ApplyTheme(Theme theme) { base.ApplyTheme(theme); if (CanApplyTheme((int)ThemeProperty.TextColor)) TextColor = theme.TextColor; if (CanApplyTheme((int)ThemeProperty.PlaceholderColor)) PlaceholderColor = theme.PlaceholderColor; } ``` ```csharp // In ActivityIndicator — accent color protected override void ApplyTheme(Theme theme) { base.ApplyTheme(theme); if (CanApplyTheme((int)ThemeProperty.Color)) Color = theme.AccentColor; } ``` #### Complete Theme Mapping Table | View | Theme Slot → Property | |---|---| | **View** (base) | `BackgroundColor` → `BackgroundColor` | | **Label** | `TextColor` → `TextColor` | | **Button** | `TextColor` → `TextColor`, `AccentColor` → `BackgroundColor` | | **Entry** | `TextColor` → `TextColor` | | **Editor** | `TextColor` → `TextColor`, `PlaceholderColor` → `PlaceholderColor` | | **SearchBar** | `TextColor` → `TextColor`, `PlaceholderColor` → `PlaceholderColor` | | **DatePicker** | `TextColor` → `TextColor` | | **Picker** | `TextColor` → `TextColor` | | **Border** | `StrokeColor` → `Stroke` | | **ActivityIndicator** | `AccentColor` → `Color` | Views that only inherit the base `BackgroundColor` mapping (no override): Image, ImageButton, Switch, Slider, ProgressBar, WebView, ScrollView, StackLayout, ContentView, Grid, BoxView, CheckBox, RadioButton, TimePicker. ### Step 2: Tracking "Developer Set" vs "Theme Set" When a developer explicitly sets a color on a view, that value takes priority over the theme. This is tracked with a bitmask (`_explicitProps`) and a `ThemeProperty` flags enum: ```csharp public partial class View { /// /// Built-in theme property flags. An Int32 supports up to 32 properties. /// Custom views can define additional flags starting at 1 << 5. /// [Flags] public enum ThemeProperty { None = 0, BackgroundColor = 1 << 0, TextColor = 1 << 1, PlaceholderColor= 1 << 2, Stroke = 1 << 3, Color = 1 << 4, } bool _isApplyingTheme; int _explicitProps; /// /// Tracks whether a theme property was explicitly set by the developer. /// When value is non-null the flag is set; when null it is cleared. /// public void TrackExplicit(int property, object? value) { if (!_isApplyingTheme) { if (value is not null) _explicitProps |= property; else _explicitProps &= ~property; } } /// /// Returns true when the theme property has not been explicitly set. /// public bool CanApplyTheme(int property) => (_explicitProps & property) == 0; } ``` Each view hooks its `On{Prop}Changing` partial to call `TrackExplicit`: ```csharp // In View partial void OnBackgroundColorChanging(Color? value) => TrackExplicit((int)ThemeProperty.BackgroundColor, value); // In Label partial void OnTextColorChanging(Color? value) => TrackExplicit((int)ThemeProperty.TextColor, value); // In Border partial void OnStrokeChanging(Color? value) => TrackExplicit((int)ThemeProperty.Stroke, value); ``` > **Why a bitmask instead of per-property booleans?** A single `int` tracks up to 32 > properties with no per-field memory overhead. The `ThemeProperty` enum provides > compile-time safety for the flag values. To **clear** an explicit override and revert to the theme: ```csharp label.TextColor = null; // clears the flag, theme value applies on next theme application ``` ### Step 3: Walking the View Tree When `Application.Theme` is set or changed, the entire `Main` view tree is walked via `ApplyThemeToTree` (an `internal static` method on `View`). Each view is themed through `ApplyThemeInternal`, which manages the `_isApplyingTheme` flag, stores the applied theme for dynamic children, and delegates to the virtual `ApplyTheme`: ```csharp // In View internal void ApplyThemeInternal(Theme theme) { _isApplyingTheme = true; _appliedTheme = theme; if (!_themeChildrenSubscribed) { _themeChildrenSubscribed = true; Children.CollectionChanged += OnThemeChildrenChanged; } ApplyTheme(theme); _isApplyingTheme = false; } internal static void ApplyThemeToTree(View? view, Theme theme) { if (view is null) return; view.ApplyThemeInternal(theme); foreach (var child in view.Children) ApplyThemeToTree(child, theme); } ``` In `Application`, theme changes subscribe to `PropertyChanged` for live updates. Setting `Main` after a theme also applies the theme to the new tree. Explicitly setting `Theme` disables `UseSystemTheme`: ```csharp // In Application partial void OnThemeChanging(Theme? value) { if (!_isSettingSystemTheme) UseSystemTheme = false; } partial void OnThemeChanged(Theme? oldValue, Theme? newValue) { if (oldValue is not null) oldValue.PropertyChanged -= OnThemePropertyChanged; if (newValue is not null) { newValue.PropertyChanged += OnThemePropertyChanged; ApplyThemeToTree(Main, newValue); } } partial void OnMainChanged(View? oldValue, View? newValue) { if (newValue is not null && Theme is not null) ApplyThemeToTree(newValue, Theme); } void OnThemePropertyChanged(object? sender, PropertyChangedEventArgs e) { if (Theme is not null) ApplyThemeToTree(Main, Theme); } ``` ### Step 4: New Views Get the Theme Too When a view is added to the tree at runtime, it picks up the current theme. Each view lazily subscribes to its own `Children.CollectionChanged` the first time `ApplyThemeInternal` is called, and themes any newly added children: ```csharp // In View — registered lazily inside ApplyThemeInternal void OnThemeChildrenChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.NewItems is not null && _appliedTheme is not null) { foreach (View child in e.NewItems) ApplyThemeToTree(child, _appliedTheme); } } ``` ## Dark Mode / Light Mode ### Simple Toggle ```csharp public class App : Application { public App() { Theme = Theme.Light; var toggle = new Switch { Toggled = sw => Theme = sw.IsOn ? Theme.Dark : Theme.Light }; Main = new StackLayout { new Label { Text = "Hello, Spice 🌶" }, new Label { Text = "Dark Mode:" }, toggle, }; } } ``` Flipping the `Switch` swaps the entire theme — every view in the tree updates immediately. ### Automatic System Appearance Detection Spice provides `PlatformAppearance` — a cross-platform static class that exposes the system's current appearance and a change notification: ```csharp public static partial class PlatformAppearance { /// /// Event raised when the system appearance changes. /// The bool parameter is true when the system switched to dark mode. /// public static event Action? Changed; /// /// Gets whether the system is in dark mode. /// public static bool IsDarkMode { get; } } ``` Set `Application.UseSystemTheme = true` and Spice handles the rest: ```csharp public class App : Application { public App() { UseSystemTheme = true; // auto-selects Theme.Light or Theme.Dark based on OS Main = new StackLayout { new Label { Text = "Hello, Spice 🌶" }, }; } } ``` When `UseSystemTheme` is enabled: - On startup, Spice queries `PlatformAppearance.IsDarkMode` and sets `Theme` accordingly - It subscribes to `PlatformAppearance.Changed` so that `Theme` is swapped automatically when the OS appearance changes - Setting `Theme` explicitly disables `UseSystemTheme` (explicit wins) - When `UseSystemTheme` is disabled, the `Changed` subscription is removed ```csharp partial void OnUseSystemThemeChanged(bool value) { if (value) { PlatformAppearance.Changed += OnPlatformAppearanceChanged; _isSettingSystemTheme = true; Theme = PlatformAppearance.IsDarkMode ? Theme.Dark : Theme.Light; _isSettingSystemTheme = false; } else { PlatformAppearance.Changed -= OnPlatformAppearanceChanged; } } void OnPlatformAppearanceChanged(bool isDarkMode) { if (_useSystemTheme) { _isSettingSystemTheme = true; Theme = isDarkMode ? Theme.Dark : Theme.Light; _isSettingSystemTheme = false; } AppearanceChanged?.Invoke(isDarkMode); } ``` Platform implementations behind `PlatformAppearance.IsDarkMode`: - **iOS** — reads `UITraitCollection.CurrentTraitCollection.UserInterfaceStyle`; listens for trait changes via `RegisterForTraitChanges` (iOS 17+) or `TraitCollectionDidChange`. - **Android** — reads `UiMode.NightMask` from `Resources.Configuration`; listens for configuration changes in `SpiceActivity.OnConfigurationChanged`. For fully custom themes that still track the OS mode, use the `AppearanceChanged` callback: ```csharp AppearanceChanged = isDark => Theme = isDark ? myDarkTheme : myLightTheme; ``` ## Custom Themes ### Extend the Built-In Slots Developers can create their own themes by simply constructing `Theme` with different colors: ```csharp var corporate = new Theme { TextColor = Color.FromArgb("#333333"), BackgroundColor = Color.FromArgb("#F5F5F5"), AccentColor = Color.FromArgb("#FF6600"), // brand orange StrokeColor = Color.FromArgb("#CCCCCC"), PlaceholderColor = Color.FromArgb("#999999"), }; app.Theme = corporate; ``` ### Add New Color Slots (Subclass) For app-specific color slots that built-in views don't know about: ```csharp public partial class BrandTheme : Theme { [ObservableProperty] Color? _headerColor; [ObservableProperty] Color? _cardBackgroundColor; } ``` Custom views consume these in their own `ApplyTheme`: ```csharp public partial class HeaderView : Label { protected override void ApplyTheme(Theme theme) { base.ApplyTheme(theme); if (theme is BrandTheme brand && brand.HeaderColor is not null) TextColor = brand.HeaderColor; } } ``` ## Per-View Overrides Setting a color explicitly on a view always wins over the theme: ```csharp app.Theme = Theme.Dark; // TextColor = White var label = new Label { Text = "Always red", TextColor = Colors.Red }; // TextColor stays Red even though the theme says White ``` To reset a view back to the theme's color: ```csharp label.TextColor = null; // Reverts to theme's TextColor on next theme application ``` ## Design Decisions ### Why Not a Dictionary / Resource System? Frameworks like WPF and MAUI use dictionaries (`ResourceDictionary`) where theme values are looked up by string key at runtime. This is flexible but: | | Dictionary (WPF/MAUI) | Typed Theme (Spice) | |---|---|---| | **NativeAOT safe** | ❌ Often uses reflection for type conversion | ✅ Plain properties, zero reflection | | **Trimmer safe** | ⚠️ String keys can't be statically analyzed | ✅ Direct property access, fully trimmable | | **Compile-time safety** | ❌ Typo in key = runtime error | ✅ Typo in property name = compile error | | **Discoverability** | ❌ Keys are strings, need documentation | ✅ IntelliSense shows all available slots | | **Debuggability** | ❌ Opaque dictionary lookups | ✅ Step through `ApplyTheme` line by line | A typed `Theme` class trades some flexibility (you can't add arbitrary keys at runtime) for compile-time safety, IntelliSense, and zero runtime overhead. This matches Spice's philosophy. ### Why Not Expression Trees / Compiled Lambdas? Expression trees would let us infer property names automatically, but they require `System.Linq.Expressions` which is not fully NativeAOT safe and increases binary size. Explicit virtual methods are zero-overhead and always trimmable. ### Why a Bitmask for Override Tracking? A single `int _explicitProps` field tracks up to 32 themeable properties with zero per-field memory overhead. The `ThemeProperty` flags enum provides compile-time safety for the bit positions. Custom views can define additional flags starting at `1 << 5`. ### Why `protected virtual` Instead of `public virtual`? `ApplyTheme` is a framework implementation detail — developers don't call it directly, they set `Application.Theme` and the framework handles the rest. Making it `protected` keeps it out of the public API surface while still allowing external libraries and custom controls to override it and participate in theming. ### Why Re-Apply the Entire Theme on Single-Property Changes? When `Theme.TextColor` changes, we re-apply the full theme to the view tree instead of only updating text colors. This keeps the logic simple — `ApplyTheme` is the single source of truth for all theme→view mappings. Theme objects are small (a handful of color properties), and the view tree traversal is fast (layout trees are typically shallow). ## Full Example ```csharp public class App : Application { public App() { Theme = Theme.Light; int count = 0; var label = new Label { Text = "Hello, Spice 🌶" }; var counter = new Label { Text = "Times: 0" }; var button = new Button { Text = "Tap me", Clicked = _ => counter.Text = $"Times: {++count}", }; var darkModeSwitch = new Switch { Toggled = sw => Theme = sw.IsOn ? Theme.Dark : Theme.Light, }; var overrideLabel = new Label { Text = "I'm always red", TextColor = Colors.Red, // explicit override — theme won't touch this }; Main = new StackLayout { label, counter, button, new StackLayout { Orientation = Orientation.Horizontal, new Label { Text = "Dark Mode:" }, darkModeSwitch, }, overrideLabel, }; } } ``` Toggle the switch → every view updates to dark colors instantly, except the red label which keeps its explicit color. ## Summary - **`Theme`** is a POCO extending `ObservableObject` — just typed color properties, no reflection - **`Application.Theme`** sets the active theme and walks the view tree - **Live updates** — change a theme property or swap the entire theme, views update immediately - **Explicit overrides win** — set `TextColor = Colors.Red` and the theme won't touch it; set to `null` to revert - **Bitmask tracking** — `ThemeProperty` flags + `_explicitProps` track developer-set vs theme-set properties - **Dynamic children** — views added to the tree at runtime automatically receive the current theme - **Dark/Light mode** — `UseSystemTheme = true` auto-detects OS appearance; or swap manually with one assignment - **Custom themes** — subclass `Theme` or just construct one with your own colors - **NativeAOT safe** — zero reflection, fully trimmable, compile-time type safety ================================================ FILE: docs/VIEW-LIFECYCLE-SPEC.md ================================================ # View Lifecycle & Cleanup in Spice 🌶 **Status:** Design Specification **Created:** February 2026 ## Overview Spice Views are created lazily and kept alive by their parent hierarchy. To support proper cleanup (e.g., disposing event subscriptions from `Bind()`), we propose a simple contract: **if a View implements `IDisposable`, its `Dispose()` will be called when the view is no longer needed.** This spec defines when and how disposal happens on each platform. ## Current State - **Android:** Views are C# partial classes wrapping `Android.Views.ViewGroup` - **iOS:** Views are C# partial classes wrapping `UIView` - **Lifecycle:** Managed implicitly by platform (Activity destruction on Android, ARC on iOS) - **Disposal:** Currently no explicit cleanup contract — memory leaks possible if custom Views hold subscriptions ## Proposed: IDisposable Contract **Key principle:** Only custom Views that hold resources implement `IDisposable` (opt-in). Built-in Views (Button, Label, StackLayout, etc.) do **not** implement `IDisposable`. ### What Gets Disposed 1. **Custom Views that implement `IDisposable`** — `Dispose()` is called when the view is destroyed 2. **Children of a disposed view** — if a parent is disposed, it recursively disposes children that are `IDisposable` 3. **Resources:** Event subscriptions, timers, and `Bind()` subscriptions can be freed this way ### Platform Semantics #### Android When the `Application`'s `Activity` is destroyed (user backs out, etc.): 1. Each View's `NativeView` (underlying `ViewGroup`) is removed from the hierarchy 2. **Top-level app View** — call `Dispose()` if it implements `IDisposable` 3. **Recursively dispose children** — foreach child in `Children`, call `Dispose()` if it's `IDisposable` 4. **Bottom-up** — children disposed before parents ```csharp // Pseudo-code in MainActivity.OnDestroy or Activity.OnDestroy equivalent protected override void OnDestroy() { if (_appView is IDisposable disposable) disposable.Dispose(); base.OnDestroy(); } ``` #### iOS When a View is removed from the hierarchy (e.g., navigation pop, cell recycle): 1. View is **no longer strongly referenced** by its parent's `Children` collection 2. **ARC (Automatic Reference Counting) deinit** is called on the Spice View wrapper 3. In the Spice View's `deinit` (C# finalizer or explicit cleanup), call `Dispose()` if it's `IDisposable` 4. **Recursively dispose children** — same as Android Since Spice Views are managed objects in a `Children` collection, we can wrap the collection removal to trigger cleanup: ```csharp // In iOS View.cs void OnChildrenChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.Action == NotifyCollectionChangedAction.Remove) { foreach (var item in e.OldItems ?? []) { if (item is View view && view is IDisposable disposable) disposable.Dispose(); } } // ... also handle Replace, etc. } ``` #### CollectionView Recycling **Special case:** CollectionView recycles ItemTemplate views. When a template view is recycled: 1. The item stays alive (kept in `ItemsSource`) 2. The **view wrapper** is removed from the hierarchy 3. Any `Bind()` subscriptions on the item should be disposed **OR** left alive if the item is reused **Recommendation:** For CollectionView, don't dispose item ViewModels (they're recycled). Instead, use weak references or check `IsVisible` before updating: ```csharp // Don't dispose the TodoItem itself — it will be recycled // The Bind() subscription will be garbage collected when the view is recycled ``` ## Implementing IDisposable in Spice Views ### Pattern 1: Single IDisposable Resource ```csharp public partial class TimerView : StackLayout, IDisposable { private System.Timers.Timer? _timer; private IDisposable? _binding; public TimerView() { var vm = new TimerViewModel(); var label = new Label(); // This subscription needs cleanup _binding = vm.Bind(nameof(vm.Elapsed), v => v.Elapsed, elapsed => label.Text = $"{elapsed}s"); // And a timer that needs stopping _timer = new System.Timers.Timer(1000); _timer.Elapsed += (_, _) => vm.Tick(); Children.Add(label); } public void Dispose() { _binding?.Dispose(); _timer?.Dispose(); } } ``` ### Pattern 2: CompositeDisposable ```csharp public partial class FormView : StackLayout, IDisposable { private readonly List _subscriptions = new(); public FormView() { var vm = new FormViewModel(); // ... create UI ... _subscriptions.Add(vm.Bind(nameof(vm.IsValid), ...)); _subscriptions.Add(vm.Bind(nameof(vm.ErrorMessage), ...)); } public void Dispose() { foreach (var sub in _subscriptions) sub?.Dispose(); _subscriptions.Clear(); } } ``` ## When to Dispose ### ✅ DO dispose: - Custom `Bind()` subscriptions when the binding outlives the view - Event subscriptions (timers, HTTP clients, etc.) - File handles, database connections - WeakReference holders that prevent collection ### ❌ DON'T dispose: - Items in `ItemsSource` (they're reused across recycled views) - Shared singletons (Application state, Services) - Views that are still in the hierarchy (parent disposes them) ## Default Behavior (No Explicit Dispose) If a View does **not** implement `IDisposable`, nothing special happens—the view is garbage collected when no longer referenced, and any subscriptions are dropped naturally. This is fine for simple Views. Only implement `IDisposable` if your View holds resources that need explicit cleanup. ## Implementation Roadmap ### Phase 1: Documentation (this spec) ✓ - Establish the contract: custom Views can implement `IDisposable` - Show patterns and examples - Guide developers on when to implement `IDisposable` ### Phase 2: Platform Integration (future) **Android:** Call `Dispose()` on the app's top-level View during `Activity.OnDestroy()` ```csharp protected override void OnDestroy() { if (_appView is IDisposable disposable) disposable.Dispose(); base.OnDestroy(); } ``` **iOS:** Hook `Children.CollectionChanged` to dispose removed views ```csharp // In View.cs private void OnChildrenChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.Action == NotifyCollectionChangedAction.Remove) { foreach (var item in e.OldItems ?? []) { if (item is IDisposable disposable) disposable.Dispose(); } } // ... also handle Replace, etc. } ``` ## Example: Todo App with Cleanup ```csharp public partial class TodoItemView : StackLayout, IDisposable { private IDisposable? _binding; public TodoItemView(TodoItem item) { var label = new Label(); var checkbox = new CheckBox(); // This subscription must be cleaned up when the view is recycled _binding = item.Bind(nameof(item.Title), t => t.Title, text => label.Text = text); item.Bind(nameof(item.IsCompleted), t => t.IsCompleted, done => checkbox.IsChecked = done); checkbox.CheckedChanged = cb => item.IsCompleted = cb.IsChecked; Children.Add(new StackLayout { label, checkbox }); } public void Dispose() { _binding?.Dispose(); // Bindings to checkbox are implicitly cleaned up with the view } } public class TodoListApp : Application { public TodoListApp() { var vm = new TodoListViewModel(); var collectionView = new CollectionView { ItemsSource = vm.Items, ItemTemplate = item => new TodoItemView((TodoItem)item) }; Main = new StackLayout { collectionView }; } } ``` ## Notes - **No finalizers:** Avoid finalizers in Views — they create GC pressure. Rely on explicit disposal or being kept alive with the view until it's cleared. - **Parent owns children:** When a parent View is disposed, it should dispose its children. - **Weak references:** For internal caches or event handlers, use WeakReference to avoid circular dependencies. ## Summary - Only custom Views implement `IDisposable` (opt-in) — built-in Views do not - Disposal is recursive: when a View is disposed, children that implement `IDisposable` are disposed too - Use this contract to clean up `Bind()` subscriptions and other resources - Platform integration will make this automatic (future) ================================================ FILE: global.json ================================================ { "msbuild-sdks": { "Microsoft.Build.NoTargets": "3.7.56" } } ================================================ FILE: samples/HeadToHeadMaui/App.xaml ================================================  ================================================ FILE: samples/HeadToHeadMaui/App.xaml.cs ================================================ namespace HeadToHeadMaui; public partial class App : Application { public App() { InitializeComponent(); MainPage = new AppShell(); } } ================================================ FILE: samples/HeadToHeadMaui/AppShell.xaml ================================================ ================================================ FILE: samples/HeadToHeadMaui/AppShell.xaml.cs ================================================ namespace HeadToHeadMaui; public partial class AppShell : Shell { public AppShell() { InitializeComponent(); } } ================================================ FILE: samples/HeadToHeadMaui/HeadToHeadMaui.csproj ================================================  net10.0-android;net10.0-ios $(TargetFrameworks);net10.0-windows10.0.19041.0 Exe HeadToHeadMaui true true enable HeadToHeadMaui com.companyname.headtoheadmaui fc6b6cda-cd30-40ab-9968-ef7e9e292c2d 1.0 1 16.0 21.0 10.0.17763.0 10.0.17763.0 6.5 r8 android-arm64 ================================================ FILE: samples/HeadToHeadMaui/HeadToHeadMaui.sln ================================================  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31611.283 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HeadToHeadMaui", "HeadToHeadMaui.csproj", "{B221E782-7C39-468D-8FCB-F16F24A90A2E}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {B221E782-7C39-468D-8FCB-F16F24A90A2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B221E782-7C39-468D-8FCB-F16F24A90A2E}.Debug|Any CPU.Build.0 = Debug|Any CPU {B221E782-7C39-468D-8FCB-F16F24A90A2E}.Debug|Any CPU.Deploy.0 = Debug|Any CPU {B221E782-7C39-468D-8FCB-F16F24A90A2E}.Release|Any CPU.ActiveCfg = Release|Any CPU {B221E782-7C39-468D-8FCB-F16F24A90A2E}.Release|Any CPU.Build.0 = Release|Any CPU {B221E782-7C39-468D-8FCB-F16F24A90A2E}.Release|Any CPU.Deploy.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {61F7FB11-1E47-470C-91E2-47F8143E1572} EndGlobalSection EndGlobal ================================================ FILE: samples/HeadToHeadMaui/MainPage.xaml ================================================