Functional MAUI control based on SwiftUI view

Artem Denisov
8 min readDec 4, 2022

--

In the first article of this series, I looked at how to use SwiftUI components as iOS implementations for MAUI controls. To demonstrate this, I used the HelloWorldView component, which only displays the “HelloWorld” label on an orange background. Not a great feature, is it?

So, in this article, I am going to demonstrate a much more complete component and show not only how to display SwiftUI controls in MAUI applications but also how to bind properties from SwiftUI implementation to MAUI code to observe changes in these properties in both directions: from Swift code to C# and vice versa.

In the context of this article, I am going to create a counter component. In terms of C# frameworks, it is usually called SpinEdit. Such control provides a way to display numbers (and only numbers) in the input field and use a pair of arrow buttons to decrease or increase the value in the box by a given interval. The user can also edit the displayed value himself.

The appearance of SpinEdit

Define the component specifications. To provide the necessary functionality, it should have the Value property, which stores the current value displayed by the component. Also, it should have the Interval property, which indicates how much value increases or decreases when a user clicks on the arrows. I also decided to add a couple of properties that manage the color of the border around the input box, showing when the editor is in focus and when it’s not.

The final specification of the MAUI control looks like this:

class SpinEditControl {
public decimal Value { get; set; }
public decimal Interval { get; set; }
public Brush BorderColor { get; set; }
public Brush FocusedBorderColor { get; set; }
}

I am not going to describe the creation and configuration of projects in detail as I did in the previous article. Here, I will focus only on the source code of the component implementation.

Same as the HelloWorld component from the previous post, SpinEdit will consist of four parts:

  • SpinEditView — SwiftUI view
  • SpinEditWrapper — UIKit wrapper over SwiftUI element to interact with
  • SpinEditHandler — MAUI class which binds Swift component and .NET MAUI control
  • SpinEditControl — .NET MAUI control

At first, I want to limit the functionality of the SpinEdit component to the Value property only. And on the example of only one property, I am going to show the whole process of creating the component.

Explore the development of all these parts in order.

SpinEditView

Let’s write code for SpinEditView.

struct SpinEditView: View {
@ObservedObject var wrapper: SpinEditWrapper

var body: some View {
VStack{
Button {wrapper.value += 1} label: {
Image(systemName: "chevron.up")
.resizable()
.frame(width: 40, height: 20)
}

TextField("Number", value: $wrapper.value, format: .number)
.multilineTextAlignment(.center)
.background(){
RoundedRectangle(cornerRadius: 4)
.stroke(.gray, lineWidth: 2)
}

Button {wrapper.value -= 1} label: {
Image(systemName: "chevron.down")
.resizable()
.frame(width: 40, height: 20)
}
}
}
}

In the code above, you can see that SpinEditView significantly differs from HelloWorldView in the earlier article. In addition to the body section, it appears with the description of some wrapper. Earlier I also created a wrapper class (HelloWorldWrapper), but I didn’t use it inside HelloWorldView itself.

The wrapper class connects SwiftUI view and MAUI code because only NSObject descendant classes can be projected in C#. So the MAUI control communicates with Wrapper, listens, and reacts to changes in its properties, but at the same time, it interacts with the SwiftUI view, which listens to changes of the same properties and changes its state accordingly. So Wrapper is a kind of view model for both MAUI control and SwiftUI view.

Diagram of the relationship between the SwiftUI view, Wrapper, and the MAUI Handler

Thus, SpinEditView does not store the value itself but uses the SpinEditWrapper.value property. When a user edits the value in TextField — SpinEditWrapper.value is changed, SpinEditHandler tracks these modifications and changes the value of properties of SpinEditControl. Suppose the user changes values programmatically from MAUI by modifying SpinEditControl.Value property, the new value is passed through the chain in the opposite direction: SpinEditControl -> SpinEditHandler -> SpinEditWrapper -> SpinEditView, which redraws itself and displays the new value.

To make SpinEditView aware that the wrapper property can send notifications of changes to its properties, the property wrapper @ObservedObject must be added.

It is necessary to add property wrapper @ObservedObject so that SpinEditView knows that the wrapper can send notifications of changes to its properties.

SpinEditView is ready. Let’s create SpinEditWrapper for it.

SpinEditWrapper

Code for SpinEditWrapper.

@objc public class SpinEditWrapper: NSObject, ObservableObject {
var swiftUIView: SpinEditView?
var hostingController: UIHostingController<SpinEditView>?

@objc @Published public var value: Decimal = 0

@objc public var uiView: UIView? { hostingController?.view }

public override init() {
super.init()

swiftUIView = SpinEditView(wrapper: self)
guard let rootView = swiftUIView else { return }
hostingController = UIHostingController(rootView: rootView)
}
}

You can notice that the wrapper class code is different from the previous article. In SpinEditWapper, there is the value property because both MAUI control and SwiftUI view are going to work with it. In order for the SwiftUI framework to track changes in this property and pass them to SpinEditView, it must be marked with the property wrapper @Published. It is also important to note that to make SpinEditWrapper track its property changes, it must conform to the ObservableObject protocol.

Moreover, when instantiating the SpinEditView, I pass SpinEditWrapper as a constructor parameter, thus setting up a connection between the wrapper (view model) and the SwiftUI component (view).

Let’s build the framework and create the bindings library as described in the previous article. To simplify the build process, you can use a ready-made script from my GitHub repository (instructions on how to use it are in the ReadMe file).

After successfully executing all the commands, we will have the SwiftUI framework and the bindings library, the ApiDefinitions.cs file should have this content:

using System;
using Foundation;
using ObjCRuntime;
using UIKit;

namespace SwiftUI_MAUI_Framework
{
// @interface SpinEditWrapper : NSObject
[BaseType (typeof(NSObject), Name = "_TtC22SwiftUI_MAUI_Framework15SpinEditWrapper")]
interface SpinEditWrapper
{
// @property (nonatomic) NSDecimal value;
[Export ("value", ArgumentSemantic.Assign)]
NSDecimal Value { get; set; }

// @property (readonly, nonatomic, strong) UIView * _Nullable uiView;
[NullAllowed, Export ("uiView", ArgumentSemantic.Strong)]
UIView UiView { get; }
}
}

I’m done with Swift code, and now it’s time to do C#.

SpinEditControl

First, I will create an MAUI control with which the user will interact.

namespace MAUI_Library;

public class SpinEditControl : View {
public static readonly BindableProperty ValueProperty = BindableProperty.Create(nameof(Value), typeof(decimal), typeof(SpinEditControl), default(decimal));

public decimal Value {
get => (decimal)GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
}

In the implementation of SpinEditControl, I added just one binding property — Value. It stores the current value displayed in SpinEdit. The value from this property will be sent by SpinEditHandler to SpinEditWrapper and then to SpinEditView.

Let’s move on to the essential part of the .NET implementation, the SpinEditHandler.

SpinEditHandler

In the context of this article, I only focus on the relationship between SwiftUI and MAUI so I won’t describe in detail the Android implementation of the MAUI handler. I will only concentrate on the iOS part.

The SpinEditHandler code:

using Foundation;
using Microsoft.Maui.Graphics.Platform;
using Microsoft.Maui.Handlers;
using SwiftUI_MAUI_Framework;
using UIKit;

namespace MAUI_Library;

public partial class SpinEditHandler : ViewHandler<SpinEditControl, UIView> {
private SpinEditWrapper Wrapper { get; set; }

public SpinEditHandler() : base(PropertyMapper) {
}

private static IPropertyMapper<SpinEditControl, SpinEditHandler> PropertyMapper =
new PropertyMapper<SpinEditControl, SpinEditHandler>(ViewMapper) {
[nameof(SpinEditControl.Value)] = MapValue,
};

private static void MapValue(SpinEditHandler handler, SpinEditControl spinEditControl) {
if (handler == null || handler.Wrapper.Value == spinEditControl.Value)
return;

handler.Wrapper.Value= spinEditControl.Value;
}

protected override UIView CreatePlatformView() {
Wrapper = new SpinEditWrapper();
return Wrapper.UiView;
}
}

Since SpinEditControl has the Value property I want to synchronize with platform implementation, I need to add to the PropertyMapper record, which sets a handler that will be triggered when the Value property of SpinEditControl changes.

When the SpinEditControl.Value property changes and the SpinEditHandler tracks this change and calls the MapValue handler method, in which I pass the new value to the platform code. That way, the SwiftUI SpinEditView gets the updated value and redraws itself.

Let’s test how it works

Well, it looks like the task is done. All elements for SpinEditControl are created. I can build the MAUI component library, add the line of code with SpinEditControl to the .xaml file of the test application and enjoy the result.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:mauiLibrary="clr-namespace:MAUI_Library;assembly=MAUI_Library"
x:Class="MAUI_App.MainPage">

<ScrollView>
<VerticalStackLayout
Spacing="25"
Padding="30,0"
VerticalOptions="Center">

<Image
Source="dotnet_bot.png"
SemanticProperties.Description="Cute dot net bot waving hi to you!"
HeightRequest="200"
HorizontalOptions="Center" />

<mauiLibrary:HelloWorldControl/>

<mauiLibrary:SpinEditControl x:Name="spinEditControl"
Value="1" />

</VerticalStackLayout>
</ScrollView>

</ContentPage>
The working test app

As you can see, the SpinEdit value responds to arrow buttons, and the input box allows the user to edit the value.

Now I would check if the value of SpinEdit changes if I change it programmatically.

I have added an input box to the .xaml file of the application, associated with the Value property of the SpinEditControl.

<mauiLibrary:SpinEditControl x:Name="spinEditControl" 
Value="1" />

<Label Text="Value:"/>
<Entry BindingContext="{x:Reference spinEditControl}"
Text="{Binding Value, Mode=TwoWay}"/>

Let’s run the application and check how the Value changes.

Test how the value changes programmatically

It seems that if the value in added Entry changes, the value in SpinEditControl also changes, which means that data are successfully transferred from the MAUI code to the SwiftUI view. But suppose the value is changed using buttons, i.e. by interacting with SpinEditView directly. In that case, the value in Entry is not changed which means that the Value property of SpinEditControl is not changed too. Hence no data is passed from the Swift part to the .NET part. All that means SpinEditHendler doesn’t know anything about property changes on platform view. Let’s fix this.

Make a delegate property for SpinEditWrapper that is called when the value property changes.

@objc @Published public var value: Decimal = 0 {
didSet {
guard let handler = onValueChanged else { return }
handler(value)
}
}

@objc public var onValueChanged : ((Decimal) -> Void)?

And add a handler method to the SpinEditHandler for this delegate.

protected override UIView CreatePlatformView() {
Wrapper = new SpinEditWrapper();
Wrapper.OnValueChanged = OnValueChanged;
return Wrapper.UiView;
}

private void OnValueChanged(NSDecimal value) => VirtualView.Value = (decimal)value;

So, when SpinEditWrapper changes value, SpinEditHandler.OnValueChanged() method is called via a delegate, which updates the value of SpinEditControl.

Let’s assemble all the libraries, ensure there are no errors, and run the test application.

It works as expected

Now it seems to work as intended and the value of the Value property is synchronized between Swift and C# code.

Similarly, I have added the Interval, BorderColor, and FocusedBorderColor properties.

Let’s have a look at how the added properties work.

Nice! All properties work

Great! All the properties that we planned work as expected. This means that the challenge is complete and I have a fully functional MAUI SpinEditControl iOS part of which is implemented in Swift language with the use of UIKit and SwiftUI.

The complete code from this article is on my GitHub.

Articles in this series:

Thanks for reading, see you in future publications. Bye!

Environment specifications:

  • Xcode 14.1 (14B47b)
  • Command Line Tools 14.1 (14B47b)
  • Visual Studio 2022 for Mac Preview 17.5 Preview (17.5 build 437)
  • .NET 7.0.100
  • .NET MAUI workloads iOS/Android 7.0.49/7.0.100
  • macOS Ventura 13.0.1 (22A400)

--

--