UI Challenge - BMI Calculator using Xamarin Forms

We are in the midst of a global pandemic and WFH (work from home) situation. Our movements are pretty much restricted, and (some of us) tend to eat a lot. This means only one thing: GAINING WEIGHT. Now, I don't have a magic solution for this, other than suggesting that we should exercise a bit more, but I can help with a simple app to help you keep track of your BMI (Body Mass Index). According to National Heart, Lung and Blood Institute, Body mass index (BMI) is defined as a measure of body fat based on height and weight that applies to adult men and women. In simple terms, it is widely used as a general indicator of whether a person has a healthy body weight for their height. Specifically, the value obtained from the calculation of BMI is used to categorize whether a person is underweight, normal weight, overweight, or obese depending on what range the value falls between.

Last week I came across this gorgeous BMI Calculator app design on Dribbble. I took it as a challenge to implement this UI purely in Xamarin Forms, and without using any third-party components. Btw, when I say no third-party components, I am exempting SkiaSharp from it as it is more of a platform in itself used to build custom controls. 

The main challenge was to implement a vertical scale control for weight and height selection. Though there are a few good components available (commercial and free), I couldn't find one which serves my purpose to the dot. So, I decided to roll-out my own using SkiaSharp. 

Ok, let's start rolling then. If you want to see me build this whole app step-by-step, I have also included Youtube video links at the bottom. If you are more of a code person, you can head over to Github to download the full source code and play with it yourself. 

 

The Design

 

The Implementation

We start by creating a new Xamarin Forms app. I have decided to use Prism MVVM framework for this purpose, but you can use any other MVVM framework of your choice or no MVVM at all. Let's also add Resizetizer.NT and SkiaSharp.Views.Forms packages from Nuget. We will use Skiasharp to create our custom control. Resizetizer.NT is a great library which helps you add images to the shared project only and automatically generate scaled images in all platform project heads. 

We will use Ellipse shapes in the first page, so start by adding Shapes_Experimental flag in your app startup.

Device.SetFlags(new[] { "Shapes_Experimental" });

 

Gender Selection Page (the start page)

Let's define our gender selection page; this is out starting page for the app. We define sections for male and female. Each section has an ellipse shape with the gender image above it. The selection is handled using TapGestureRecognizer on the ellipse. We use bindings to set the selection and show the selected gender with a tick box image.

            <!-- Male -->
            <Ellipse 
                Grid.Column="0"
                Fill="{StaticResource LightBackgroundColor}"                
                StrokeThickness="0"
                WidthRequest="140"
                HeightRequest="140"
                HorizontalOptions="Center"
                VerticalOptions="End">

                <Ellipse.GestureRecognizers>
                    <TapGestureRecognizer Command="{Binding SelectGenderCommand, Mode=OneWay}" CommandParameter="Male" />
                </Ellipse.GestureRecognizers>

            </Ellipse>

            <Image
                Source="Male.png"
                Grid.Column="0"
                HeightRequest="195"
                WidthRequest="50"
                HorizontalOptions="Center"
                VerticalOptions="End"
                InputTransparent="True"
                Margin="0,0,0,16" />

            <Image
                Source="CheckedIcon.png"
                Grid.Column="0"
                WidthRequest="24"
                HeightRequest="24"
                IsVisible="{Binding IsMaleSelected, Mode=OneWay}"
                HorizontalOptions="End"
                VerticalOptions="Start" 
                Margin="0,0,12,12" />

 

And we add a button to move to the next page:

        <!-- Next Page Button -->
        <ImageButton
            Style="{StaticResource NextButtonStyle}"
            HorizontalOptions="Center"
            Command="{Binding GoToStep2PageCommand, Mode=OneWay}"/>

 

This is how it looks like so far:

 

Vertical Gauge Control

Now we move on to the meat of this application: the vertical gauge control. We will do this by creating a ContentView XAML control, and use SkiaSharp to draw the components. There are 4 things we need to focus on:

  1. The outer gauge container
  2. The major and minor gauge axis marks
  3. Drawing the indicator
  4. Touch interactions to change the value

 

This is what we are trying to create:

 

 

Let's start by adding a Skia CanvasView control in the XAML page:

<skia:SKCanvasView x:Name="guageCanvas" PaintSurface="OnPaintSurface"  />

The PaintSurface method is the key, This is where we will do the magic.

        private void OnPaintSurface(object sender, SKPaintSurfaceEventArgs e)
        {
            SKSurface surface = e.Surface;
            SKCanvas canvas = surface.Canvas;
            SKImageInfo info = e.Info;

            //Compute the Screen Density
            var width = info.Width;
            var height = info.Height;
            density = width / (float)this.Width;

            // clear the surface
            canvas.Clear(SKColors.Transparent);

            //Draw the guage container
            DrawGuageOutLine(canvas, width, height);

            //Draw Axis
            DrawGuageAxis(canvas, width, height);

            //Draw Value Indicator
            DrawValueIndicator(canvas, width, height);
        }

 

Before we dive into the implementation of these functions, let's do a little bit of house-keeping first and define a few Bindable Properties. We will use these to interact with our control from the UI.

  1. Maximum and Minimum: this defines the allowed range of the gauge control, e.g. between 30 and 80
  2. Step: the value after which a major tick mark should be displayed, e.g. a step value of 10 means that a major tick mark will be drawn at 40, 50, 60 and 70 (considering the above example)
  3. Minor Ticks: how many minor ticks to be drawn between steps
  4. Indicator Value: the actual current/selected value
  5. Indicator Value Unit: e.g. "kg" or "cm", if supplied, will be displayed along with the indicator value
  6. Indicator Direction: specifies which side the gauge should be drawn on (left or right)
        public static BindableProperty MaximumProperty = BindableProperty.Create("Maximum", typeof(float), typeof(VerticalGuage), 100f, BindingMode.OneWay, null, InvalidateGauge);

        public static BindableProperty MinimumProperty = BindableProperty.Create("Minimum", typeof(float), typeof(VerticalGuage), 0f, BindingMode.OneWay, null, InvalidateGauge);

        public static BindableProperty StepProperty = BindableProperty.Create("Step", typeof(float), typeof(VerticalGuage), 20f, BindingMode.OneWay, null, InvalidateGauge);

        public static BindableProperty MinorTicksProperty = BindableProperty.Create("MinorTicks", typeof(float), typeof(VerticalGuage), 20f, BindingMode.OneWay, null, InvalidateGauge);

        public static BindableProperty IndicatorValueProperty = BindableProperty.Create("IndicatorValue", typeof(float), typeof(VerticalGuage), 20f, BindingMode.OneWay, null, InvalidateGauge);

        public static BindableProperty IndicatorValueUnitProperty = BindableProperty.Create("IndicatorValueUnit", typeof(string), typeof(VerticalGuage), "", BindingMode.OneWay, null, InvalidateGauge);

        public static BindableProperty IndicatorDirectionProperty = BindableProperty.Create("IndicatorDirection", typeof(string), typeof(VerticalGuage), "Right", BindingMode.OneWay, null, InvalidateGauge);

And we also have a few private variables, such as paintbrushes and colors which we will use while drawing different components in the control:

        private float density;
        private float regularStrokeWidth = 3;
        private float majorStrokeWidth = 4;
        private float minorStrokeWidth = 2;
        private float indicatorStrokeWidth = 6;
        private float guageWidth = 60;

        private SKPaint regularStrokePaint, minorStrokePaint, majorStrokePaint, indicatorStrokePaint, fillPaint;
        private SKColor strokeColor = new SKColor(117, 117, 117, 230);
        private SKColor fillColor = new SKColor(117, 117, 117, 50);

 

First on, let's draw the gauge outline or container. I initially tried doing it in a simple way by using DrawRoundedRect method and though it works fine, I noticed dithering problem around the edges, so I decided to adopt a more complicated method using lines and arcs (this did involve a bit of Maths/computations), but it achieves the result perfectly. Depending on the IndicatorDirection property, I draw the container on either side of the canvas: 

        private void DrawGuageOutLine(SKCanvas canvas, int width, int height)
        {
            float verticalMargin = guageWidth / 2 + regularStrokeWidth / 2;

            using (SKPath containerPath = new SKPath())
            {
                switch (IndicatorDirection)
                {
                    case "Right":
                        containerPath.MoveTo(regularStrokeWidth / 2, verticalMargin);
                        containerPath.ArcTo(guageWidth / 2, guageWidth / 2, 0, SKPathArcSize.Large, SKPathDirection.Clockwise, guageWidth + regularStrokeWidth / 2, verticalMargin);
                        containerPath.LineTo(guageWidth + regularStrokeWidth / 2, height - verticalMargin);
                        containerPath.ArcTo(guageWidth / 2, guageWidth / 2, 0, SKPathArcSize.Large, SKPathDirection.Clockwise, regularStrokeWidth / 2, height - verticalMargin);
                        containerPath.Close();
                        break;
                    case "Left":
                        containerPath.MoveTo(width - guageWidth - regularStrokeWidth / 2, verticalMargin);
                        containerPath.ArcTo(guageWidth / 2, guageWidth / 2, 0, SKPathArcSize.Large, SKPathDirection.Clockwise, width - regularStrokeWidth / 2, verticalMargin);
                        containerPath.LineTo(width - regularStrokeWidth / 2, height - verticalMargin);
                        containerPath.ArcTo(guageWidth / 2, guageWidth / 2, 0, SKPathArcSize.Large, SKPathDirection.Clockwise, width - guageWidth - regularStrokeWidth / 2, height - verticalMargin);
                        containerPath.Close();
                        break;
                }

                canvas.DrawPath(containerPath, regularStrokePaint);
            }
        }


Next up is drawing the major and minor ticks on the gauge. This is a simple computation; find the number of major and minor ticks and draw them using lines (depending on the indicator direction):

        private void DrawGuageAxis(SKCanvas canvas, int width, int height)
        {
            var numMajorTicks = (Maximum - Minimum) / Step;
            var majorDistance = (height - guageWidth * 2) / numMajorTicks;

            var numMinorTicks = MinorTicks * numMajorTicks;
            var minorDistance = (height - guageWidth * 2) / numMinorTicks;

            for (int i = 0; i <= numMajorTicks; i++)
            {
                var start = new SKPoint(IndicatorDirection == "Right"? guageWidth : width - guageWidth, i * majorDistance + guageWidth);
                var end = new SKPoint(IndicatorDirection == "Right"? guageWidth / 2.5f : width - guageWidth / 2.5f, i * majorDistance + guageWidth);

                canvas.DrawLine(start, end, majorStrokePaint);
            }

            for (int i = 0; i <= numMinorTicks; i++)
            {
                var start = new SKPoint(IndicatorDirection == "Right" ? guageWidth : width - guageWidth, i * minorDistance + guageWidth);
                var end = new SKPoint(IndicatorDirection == "Right" ? guageWidth - guageWidth / 4 : width - guageWidth + guageWidth / 4, i * minorDistance + guageWidth);

                canvas.DrawLine(start, end, minorStrokePaint);
            }
        }

Last one is drawing the indicator value on the gauge. This actually consists of 3 parts:

  1. Drawing an indication (line/pointer) at a position pertaining to the indicator value within the gauge range (Minimum and Maximum)
  2. Drawing the value as text (along with units if provided) next to the indicator line
  3. Filling the gauge up to the extent of indicator value
        private void DrawValueIndicator(SKCanvas canvas, int width, int height)
        {
            if (IndicatorValue < Minimum || IndicatorValue > Maximum)
                return;

            float indicatorPosition = (height - guageWidth * 2) * (1f - (IndicatorValue - Minimum) / (Maximum - Minimum)) + guageWidth;
            float verticalMargin = guageWidth / 2 + regularStrokeWidth / 2;

            //Draw Indicator Line
            var start = new SKPoint(IndicatorDirection == "Right" ? guageWidth + 10 : width - guageWidth - 10, indicatorPosition);
            var end = new SKPoint(IndicatorDirection == "Right" ? guageWidth + 40 : width - guageWidth - 40, indicatorPosition);

            canvas.DrawLine(start, end, indicatorStrokePaint);

            //Draw Value Label
            using (var textPaint = new SKPaint())
            {
                textPaint.TextSize = (Device.RuntimePlatform == Device.Android )? 36 : 20;
                textPaint.IsAntialias = true;
                textPaint.Color = strokeColor;
                textPaint.IsStroke = false;

                var indicatorText = IndicatorValue.ToString("0.0") + (!String.IsNullOrEmpty(IndicatorValueUnit) ? " " + IndicatorValueUnit : "");

                if (IndicatorDirection == "Right")
                    canvas.DrawText(indicatorText, guageWidth + 40 + 10, indicatorPosition + 14, textPaint);
                else
                {
                    float textWidth = textPaint.MeasureText(indicatorText);

                    canvas.DrawText(indicatorText, width - guageWidth - 40 - 10 - textWidth, indicatorPosition + 14, textPaint);
                }
            }

            //Fill the guage
            using (SKPath containerPath = new SKPath())
            {
                switch (IndicatorDirection)
                {
                    case "Right":
                        containerPath.MoveTo(regularStrokeWidth / 2, indicatorPosition);
                        containerPath.LineTo(guageWidth + regularStrokeWidth / 2, indicatorPosition);
                        containerPath.LineTo(guageWidth + regularStrokeWidth / 2, height - verticalMargin);
                        containerPath.ArcTo(guageWidth / 2, guageWidth / 2, 0, SKPathArcSize.Large, SKPathDirection.Clockwise, regularStrokeWidth / 2, height - verticalMargin);
                        containerPath.Close();
                        break;
                    case "Left":
                        containerPath.MoveTo(width - guageWidth - regularStrokeWidth / 2, indicatorPosition);
                        containerPath.LineTo(width - regularStrokeWidth / 2, indicatorPosition);
                        containerPath.LineTo(width - regularStrokeWidth / 2, height - verticalMargin);
                        containerPath.ArcTo(guageWidth / 2, guageWidth / 2, 0, SKPathArcSize.Large, SKPathDirection.Clockwise, width - guageWidth - regularStrokeWidth / 2, height - verticalMargin);
                        containerPath.Close();
                        break;
                }

                canvas.DrawPath(containerPath, fillPaint);
            }
        }

 

And no, I have not forgotten about the Touch interactions. We need an ability for the users to select/change the value. This can easily be done using the Touch event on the Skia CanvasView. We simply amend our XAML declaration as below and define the touch event handler. In the handler, we identify the position relative to the gauge range and compute the new IndicatorValue and set it accordingly. By setting this value, the gauge will be redrawn automatically (using InvalidateGauge PropertyChangedDelegate).

<skia:SKCanvasView x:Name="guageCanvas" PaintSurface="OnPaintSurface" EnableTouchEvents="True" Touch="guageCanvas_Touch" />
        private void guageCanvas_Touch(object sender, SKTouchEventArgs e)
        {
            if (e.ActionType == SKTouchAction.Pressed ||
                e.ActionType == SKTouchAction.Released ||
                e.ActionType == SKTouchAction.Moved)
            {
                var actualHeight = Height * density;
                var y = e.Location.Y;

                if (y < guageWidth || y > actualHeight - guageWidth)
                    return;

                IndicatorValue = Convert.ToSingle(Minimum + (actualHeight - y - guageWidth) * (Maximum - Minimum) / (actualHeight - 2 * guageWidth));
            }

            e.Handled = true;
        }
        private static void InvalidateGauge(BindableObject bindable, object oldValue, object newValue)
        {
            VerticalGuage gauge = bindable as VerticalGuage;

            gauge.guageCanvas.InvalidateSurface();
        }


With the control in place, we can now move on to the page which utilizes this control and allow the user to select weight and height.

Height & Weight Selection Page

This is a very simple page that displays the two gauges (one for height selection and other for weight) and the avatar image (of the gender selected). We set the IndicatorDirection values on each of these controls appropriately along with unit values to be displayed. We also set the position and Step property differently to give it a stylish look.

        <!-- Selection Controls -->
        <Grid
            x:Name="SelectionGrid"
            Grid.Row="1"
            HorizontalOptions="FillAndExpand"
            VerticalOptions="FillAndExpand"
            ColumnDefinitions="100,*,100"
            Margin="24">

            <!-- Height Control -->
            <controls:VerticalGuage 
                x:Name="HeightControl"
                Grid.Column="0"
                HorizontalOptions="Start"
                VerticalOptions="Start"
                Maximum="220"
                Minimum="120"
                Step="10"
                MinorTicks="4"
                IndicatorValue="{Binding SelectedHeight, Mode=TwoWay}"
                IndicatorValueUnit="cm"
                IndicatorDirection="Right"
                WidthRequest="100"
                HeightRequest="250" />
            
            <!-- Avatar Image -->
            <Image
                x:Name="AvatarImage"
                Source="{Binding AvatarImage, Mode=OneWay}"
                Grid.Column="1"
                Aspect="Fill"
                HorizontalOptions="Center"
                VerticalOptions="End" />

            <!-- Weight Control -->
            <controls:VerticalGuage 
                x:Name="WeightControl"
                Grid.Column="2"
                HorizontalOptions="End"
                VerticalOptions="End"
                Maximum="200"
                Minimum="40"
                Step="20"
                MinorTicks="4"
                IndicatorValue="{Binding SelectedWeight, Mode=TwoWay}"
                IndicatorValueUnit="kg"
                IndicatorDirection="Left"
                WidthRequest="100"
                HeightRequest="200"/>

        </Grid>

 

This is how it looks:

 

To add an extra touch on interaction, we want the avatar image to be resized depending on the height and weight selected/changed on the gauge controls. We can do so easily by writing a simple function that adjusts the height and width to match that of the values selected on gauges. We are binding the IndicatorValue property on our gauges to the view model in a Two Way mode.

        private void SetDimensions()
        {
            if (viewModel.SelectedHeight == 0 || viewModel.SelectedWeight == 0)
                return;

            AvatarImage.HeightRequest = SelectionGrid.Height - 15f - gridPadding * 2 - (HeightControl.Height - 30f) * (HeightControl.Maximum - viewModel.SelectedHeight) / (HeightControl.Maximum - HeightControl.Minimum);

            AvatarImage.WidthRequest = viewModel.SelectedWeight * (this.Width - 160 - 100) / (WeightControl.Maximum - WeightControl.Minimum); 
        }

 

And we want this scale adjustment to be done in realtime whenever the values change in the gauges. We can do so by raising an event from the view model whenever these values change and subscribing to this event on our page:

In the View Model:

        public event EventHandler HeightWeightChanged;

        private float _selectedHeight = 0f;
        public float SelectedHeight
        {
            get { return _selectedHeight; }
            set 
            {
                SetProperty(ref _selectedHeight, value);

                //Raise the Event to notify the page of size changes
                HeightWeightChanged?.Invoke(this, new EventArgs());
            }
        }

        private float _selectedWeight = 0f;
        public float SelectedWeight
        {
            get { return _selectedWeight; }
            set 
            { 
                SetProperty(ref _selectedWeight, value);

                //Raise the Event to notify the page of size changes
                HeightWeightChanged?.Invoke(this, new EventArgs());
            }
        }

And in the page code-behind:

        protected override void OnAppearing()
        {
            base.OnAppearing();

            viewModel.HeightWeightChanged += ViewModel_HeightWeightChanged;
        }

        private void ViewModel_HeightWeightChanged(object sender, EventArgs e)
        {
            SetDimensions();
        }


Now, let's move on to displaying the result.

Results Display Page

The results display page is pretty simple. Nothing to highlight here except that the calculation is done in the view model as below:

        private async void ComputeBMI()
        {
            //Calculation: [weight (kg) / height (cm) / height (cm)] x 10,000
            ComputedBMI = 10000f * _selectedWeight / _selectedHeight / _selectedHeight;

            IsDataReady = true;
        }

 

 

But to spice it up a bit here, I wanted to dim out the non-selected scales and highlight only the selected one. This is done with the help of a simple converter. The converter takes two values as input (From and To) and depending on if the value if within this range, return an opacity of 1.0, else 0.4.

public class BMIOpacityConverter : IValueConverter
    {
        public float FromValue { get; set; }
        public float ToValue { get; set; }

        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            var _value = System.Convert.ToSingle(value, new NumberFormatInfo() { NumberDecimalDigits = 1 });

            if (_value >= FromValue && _value <= ToValue)
                return 1.0f;

            return 0.4f;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }

And to use it in our page:

<ContentPage.Resources>
        <localConverters:BMIOpacityConverter x:Key="cvtUnderWeightBMIOpacity" FromValue="0" ToValue="18.9"/>
        <localConverters:BMIOpacityConverter x:Key="cvtNormalBMIOpacity" FromValue="19" ToValue="24.9"/>
        <localConverters:BMIOpacityConverter x:Key="cvtOverWeightBMIOpacity" FromValue="25" ToValue="29.9"/>
        <localConverters:BMIOpacityConverter x:Key="cvtObeseBMIOpacity" FromValue="30" ToValue="39.9"/>
        <localConverters:BMIOpacityConverter x:Key="cvtSeverlyObeseBMIOpacity" FromValue="40" ToValue="54"/>
</ContentPage.Resources>

 

The Final Result

To put it all together, this is the finished application in action. You can head over to Github to grab the code and play with it yourself. If you want to see me build this app step-by-step, stay tuned to the Youtube video at the bottom.

 

 

See me build this app

 

 

 

 

Add comment

Loading