ImageButton or Image with TapGestureRecognizer in MAUI ?

MAUI includes a Button (as you’d expect) as well as an ImageButton which allows us to display (as I’m sure you guessed) and image instead of text. Sadly (at least in the current version of .NET 7.0) this does not work nicely on Windows and I want my MAUI app to deploy to iOS, Android and Windows.

An Image Button takes and image source and we can supply a command etc. like this

<ImageButton 
   Source="start.png" 
   Command="{Binding StartCommand}" />

I’ve not tested this yet on a device, but on an Android emulator the image button doesn’t have the pressed, ripple effect that a standard button has, so it’s not that obvious that it’s been clicked. However it displays the image correctly and with transparency. Much to my surprise though, on Windows, the transparency is lost and instead we get a button with some percentage of opacity, however on Windows it does look more like a button – so good and bad points.

So, at this time, the best way to display an image and respond to tap events appears to still be using an Image and TapGestureRecognizer, like this

<Image Source="start.png">
   <Image.GestureRecognizers>
      <TapGestureRecognizer Command="{Binding StartStopCommand}"/>
   </Image.GestureRecognizers>
</Image>

This still lacks the button-like feedback (such as a ripple). Using something like the AlohaKit.Animations NuGet package we could add a Tapped event to our TapGestureRecognizer and in code behind have something like

private void TapGestureRecognizer_OnTapped(object? sender, TappedEventArgs e)
{
   if (sender is View view)
   {
      view.Animate(new StoryBoard(new List<AnimationBase>
      {
         new ScaleToAnimation { Scale = 1.2, Duration = "200" },
         new ScaleToAnimation { Scale = 1, Duration = "100" }
      }));
   }
}

Whilst it’s not a button ripple effect, it does give some feedback on the image being tapped – obviously it would be worth looking at alternative animations to best suit your needs.

Unit testing your MAUI project

Note: I found this didn’t work correctly on Visual Studio for Mac, I’ll update the post further if I do get it working.

This post is pretty much a duplicate of Adding xUnit Test to your .NET MAUI Project but just simplified for me to quickly repeat the steps which allow me to write unit tests against my MAUI project code.

So, you want to unit test some code in your MAUI project. It’s not quite as simple as just creating a test project then referencing the MAUI project. Here are the steps to create an NUnit test project with .NET 7 (as my MAUI project has been updated to .NET 7).

  • Add a new NUnit Test Project to the solution via right mouse click on the solution, Add | Project and select NUnit Test Project
  • Open the MAUI project (csproj) file and prepend the net7.0 to the TargetFrameworks so it looks like this
    <TargetFrameworks>net7.0;net7.0-android;net7.0-ios;net7.0-maccatalyst</TargetFrameworks>
    
  • Replace <OutputType>Exe</OutputType> with the following
    <OutputType Condition="'$(TargetFramework)' != 'net7.0'">Exe</OutputType>
    

    Yes, this is TargetFramework singular. You may need to reload the project.

  • Now in your unit test project you can reference this MAUI project and write your tests

So, why did we carry out these steps?

Our test project was targeting .NET 7.0 but our MAUI project was targeting different platform implementations of the .NET 7 frameworks, i.e those for Android etc. We need the MAUI project to build a .NET 7.0 compatible version hence added the net7.0 to the TargetFrameworks.

The change to add the OutputType ensures that we only build an EXE output for those other frameworks, and therefore for .NET 7.0 we’ll have a DLL to reference instead in our tests.

Now we can build and run our unit tests.

Swift’s @discardableResult

When we’re writing Swift code we might have a function that returns a value as part of its invocation. For example, maybe we’ve a function which sends some data to a server and then returns a boolean to denote success or failure, here’s a mock up

func serviceCall() -> Bool {
    // do something real here
    true
}

We call this method but maybe we don't care what the result is, so we simply call it like this

[code]
serviceCall()

If we build this, swift will report warning: result of call to ‘serviceCall()’ is unused serviceCall(). We can get around this using a discard, for example

let _ = serviceCall()
// OR
_ = serviceCall()

but there’s another way to tell the compiler to ignore this warning. We mark the function with the @discardableResult attribute, i.e.

@discardableResult
func serviceCall() -> Bool {
    // do something real here
    true
}

Admittedly this seems a little odd, that the function being called should say, “hey you can just ignore the result”. This is something that should probably be used with care, although if it’s known that the function’s result is likely to be ignored, then it’s better than having lots of discards all over the place to ignore the result of the function.

Conditional Compilation with Swift

Occasional you’ll need to write code blocks that are specific to an OS (as I’ve found Swift on Linux and on Mac OS is not always 100% in sync with features).

In which case we can use conditional compilation blocks, such as

#if os(Linux)
// code specific to Linux
#elseif os(Windows)
// code specific to Windows
#endif

The os() condition accepts, macOS, iOS, watchOS, tvOS, Linux and Windows.

Other conditions include architecture arch() with options i386, x86_64, arm and arm64.

Swift also allows supports canImport which can be used to check if a module is available, like this

#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

See Conditional Compilation Block from the Swift language manual for a full list of conditions.

Swift unit tests, including and using .json files

Note: I’m running everything on Ubuntu 20.0.4 using Swift version 5.7.1. I’m assuming everything listed below works on Mac etc. as well.

I have a simple Eureka client package (named SwiftEureka) that I’ve been working on. Now I’ve tested it against a running instance of Eureka but I want to write some unit tests where I don’t need the server running. The idea simply being that I have a bunch of .json files which contain the JSON responses, taken from Eureka, for various REST calls.

So, to summarise, I basically want to have .json files within my Tests folder and use them within my unit tests.

Let’s assume we have our Tests/EurekaTests folder and within that we have a file named applications.json. We need to add this to the Package.swift file under the .testTarget section, like this

.testTarget(
   name: "EurekaTests",
   dependencies: ["SwiftEureka"],
      resources: [
         .process("applications.json")
      ]),

Now the file will be seen by Swift as part of the package’s bundle.

Next, we need to load the file into our tests. We do this by using the Bundle object, like this

guard let path = Bundle.module.url(forResource: "applications", withExtension: "json") else {
   XCTFail("Missing file: applications.json")
   return
}

let json = try Data(contentsOf: path)
let wrapper = try JSONDecoder().decode(ApplicationsWrapper.self, from: json)

Installing nanoFramework on the ATOM Lite ESP32 (M5Stack)

I have a wonderful little M5Stack ATOM Lite, ESP32 based dev kit to play with and as I’d had such success with the M5Core2 and nanoFramework, I thought I’d try the framework on the ATOM lite.

You, can check the device and the “Firmware Target” for the device from Recommended devices to start with .NET nanoFramework. So, for this device the target is ESP32_PICO.

If we connect your device to your computer’s USB port (hopefully the device will be recognised, if not see my previous post on setting up the M5Core2) execute the following command from the CLI, we’ll flash the device with the nanoFramework

nanoff --target ESP32_PICO --update --serialport COM10

Change the COM port to whatever your device is on. Also I’m again assuming you’ve installed nanoff, if not try running the following from the CLI “dotnet tool install -g nanoff”.

The ATOM lite comes with WiFi, bluetooth a NeoPixel RGB LED, button and even infrared.

Once you’ve installed nanoFramework, create a new nanoFramework project in Visual Studio 2022 (seem my previous posts on setting this up if you’ve not already got everything setup).

Let’s start with the LED, we’ll simply change the colour of the LED. First, you’ll need to add the package nanoFramework.AtomLite via NuGet. Next copy and paste this code into the Program.cs

while (true)
{
    AtomLite.NeoPixel.Image.SetPixel(0, 0, Color.Gray);
    AtomLite.NeoPixel.Update();

    Thread.Sleep(5000);

    AtomLite.NeoPixel.Image.SetPixel(0, 0, Color.Green);
    AtomLite.NeoPixel.Update();

    Thread.Sleep(5000);

    AtomLite.NeoPixel.Image.SetPixel(0, 0, Color.Red);
    AtomLite.NeoPixel.Update();
}

AdaptiveTrigger working in MAUI 7.x

This is just a quick update to my post Responsive, Reactive, Adaptive design in MAUI. The AdaptiveTrigger now works, so we can create adaptive UI’s like this

<?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"
             x:Class="AdaptiveTriggerTest.MainPage">
    <VisualStateManager.VisualStateGroups>
        <VisualStateGroup x:Name="Responsive">
            <VisualState x:Name="Large">
                <VisualState.StateTriggers>
                    <AdaptiveTrigger MinWindowWidth="1200" />
                </VisualState.StateTriggers>
                <VisualState.Setters>
                    <Setter Property="BackgroundColor" Value="Blue"/>

                    <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Green"/>
                    <Setter TargetName="MainLabel" Property="Label.FontSize" Value="128"/>
                </VisualState.Setters>
            </VisualState>
            <VisualState x:Name="Default">
                <VisualState.StateTriggers>
                    <AdaptiveTrigger MinWindowWidth="0" />
                </VisualState.StateTriggers>
                <VisualState.Setters>
                    <Setter Property="BackgroundColor" Value="Azure"/>

                    <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Red"/>
                    <Setter TargetName="MainLabel" Property="Label.FontSize" Value="48"/>
                </VisualState.Setters>
            </VisualState>
        </VisualStateGroup>
    </VisualStateManager.VisualStateGroups>

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

        <Label
                x:Name="MainLabel"
                Text="Hello, World!"
                SemanticProperties.HeadingLevel="Level1"
                FontSize="32"
                HorizontalOptions="Center" />
    </VerticalStackLayout>
</ContentPage>

The AdaptiveTrigger MinWindowWidth=”1200″ basically designates what the UI style etc. is for Window’s with a width >= 1200. The second AdaptiveTrigger is for anything smaller.

Turning your M5Core2 into a nanoFramework based web server

Like most of my posts regarding nanoFramework and the M5Core2, I owe a debt to those who created this stuff and I’m really just going through some of the samples etc. Trying them out and documenting my findings. This post is no different, it’s based on the Welcome to the .NET nanoFramework WebServer repository

Add the NuGet package nanoFramework.WebServer to your nanoFramework project.

You’ll need to also include code to connect to your WiFi, so checkout my post on that subject – Wifi using nanoFramework on the M5Core2.

Assuming you’ve connected to your WiFi, we can set up a WebServer like this

using var server = new WebServer(80, HttpProtocol.Http, new[] { typeof(PowerController) });
server.Start();

Thread.Sleep(Timeout.Infinite);

The first line supplies an array of controllers, so you can have multiple controllers for your different endpoints. In this case we’ve just got the single controller PowerController. This is a simple class that includes RouteAtrribute and MethodAttribute adorned methods to acts as routes/endpoints.

Let’s look at the PowerController, which just returns some M5Core2.Power values when accessed via http://m5core2_ip_address/power.

public class PowerController
{
   [Route("power")]
   [Method("GET")]
   public void PowerRoute(WebServerEventArgs e)
   {
      var power = M5Core2.Power;
           
      var sb = new StringBuilder();
      sb.AppendLine("Power:");
      sb.AppendLine($"  Adc Frequency: {power.AdcFrequency}");
      sb.AppendLine($"  Adc Pin Current: {power.AdcPinCurrent}");
      sb.AppendLine($"  Adc Pin Current Setting: {power.AdcPinCurrentSetting}");
      sb.AppendLine($"  Adc Pin Enabled: {power.AdcPinEnabled}");
      sb.AppendLine($"  Batt. Temp. Monitor: {power.BatteryTemperatureMonitoring}");
      sb.AppendLine($"  Charging Current: {power.ChargingCurrent}");
      sb.AppendLine($"  Charging Stop Threshold: {power.ChargingStopThreshold}");
      sb.AppendLine($"  Charging Voltage: {power.ChargingVoltage}");
      sb.AppendLine($"  Dc Dc1 Voltage: {power.DcDc1Voltage.Millivolts} mV");
      sb.AppendLine($"  Dc Dc2 Voltage: {power.DcDc2Voltage.Millivolts} mV");
      sb.AppendLine($"  Dc Dc3 Voltage: {power.DcDc3Voltage.Millivolts} mV");
      sb.AppendLine($"  EXTEN Enable: {power.EXTENEnable}");
      sb.AppendLine($"  VOff Voltage: {power.VoffVoltage}");
      sb.AppendLine($"  Gpio0 Behavior: {power.Gpio0Behavior}");
      sb.AppendLine($"  Gpio0 Value: {power.Gpio0Value}");

      e.Context.Response.ContentType = "text/plain";
      WebServer.OutPutStream(e.Context.Response, sb.ToString());
}

As you can see from the last line of code, we send the response back with our payload, the string of power information.

We can also return HTTP codes using

WebServer.OutputHttpCode(e.Context.Response, HttpStatusCode.OK);

This is great, but what’s the IP address of our IoT device, so I can access the web server?

Well, ofcourse you could check your router or DHCP server, but better still, let’s output the IP address to the M5Core2 screen using

Console.WriteLine(IPGlobalProperties.GetIPAddress().ToString());

We can support multiple routes per method, such as

[ublic class PowerController
{
[Route("power")]
[Route("iotpower")]
[Method("GET")]
public void PowerRoute(WebServerEventArgs e)
{
// code removed
}

Note: Routes are usually case insensitive, unless you add the CaseSensitiveAttribute to your method.

Interacting with the M5Core2 Accelerometer and Gryoscope using nanoFramework

The M5Core includes an accelerometer which allows us to measure the rate of acceleration, as well as a gyroscope to sense angular movement.

We initialize the combined AccelerometerGyroscope and calibrate it by using the following code. The number, 100 in this case, is the number of iterations to calibrate the AccelerometerGyroscope

M5Core2.AccelerometerGyroscope.Calibrate(100);

Let’s look at the code to read the accelerometer and gyroscope (we’ll also read the internal temperature of the AccelerometerGyroscope)

Console.Clear();

M5Core2.AccelerometerGyroscope.Calibrate(100);

while (true)
{
   var accelerometer = M5Core2.AccelerometerGyroscope.GetAccelerometer();
   var gyroscope = M5Core2.AccelerometerGyroscope.GetGyroscope();
   var temperature = M5Core2.AccelerometerGyroscope.GetInternalTemperature();

   Console.CursorLeft = 0;
   Console.CursorTop = 1;

   Console.WriteLine("Accelerator:");
   Console.WriteLine($"  x={accelerometer.X}");
   Console.WriteLine($"  y={accelerometer.Y}");
   Console.WriteLine($"  z={accelerometer.Z}");
   Console.WriteLine("Gyroscope:");
   Console.WriteLine($"  x={gyroscope.X}");
   Console.WriteLine($"  y={gyroscope.Y}");
   Console.WriteLine($"  z={gyroscope.Z}");
   Console.WriteLine("Internal Temp:");
   Console.WriteLine($"  Celsius={temperature.DegreesCelsius}");

   Thread.Sleep(20);
}

nanoFramework Console (using the M5Core2)

The nanoFramework comes with a Console class, for the M5Stack this is in the namespace nanoFramework.M5Stack.Console

Before we uses the Console we need to initialize the screen, this essentially creates the screen buffer and assigns a font from the application’s resource. As I’m testing this stuff on the M5Core2, the code looks like this.

M5Core2.InitializeScreen();

Now we can simply use the Console like we would for a Console application on Windows.

// clear the console
Console.Clear();

// output some test
Console.WriteLine("Some Text");

// change the foreground colour
Console.ForegroundColor = Color.Red;
Console.WriteLine("Some Red Text");

// change foreground and background colours
Console.BackgroundColor = Color.Yellow;
Console.ForegroundColor = Color.White;
Console.WriteLine("Some Green Text on Yellow Background");

We can also change the font by supplying a font resource, the default included is consolas_regular_16.tinyfnt. We would add the font as a resource and create the font like this

Console.Font = Resource.GetFont(Resource.FontResources.consolas_regular_16);

We can move the cursor around using

Console.CursorLeft = 3;
Console.CursorTop = 5;

We can also get the height and width of our window via the Console using

Console.WriteLine($"Height: {Console.WindowHeight}, Width: {Console.WindowWidth}");