Search code examples
c#.netmaui.net-maui

.NET MAUI: Customize datepicker so that it opens when clicking on an icon


I want to display a calendar icon, then when the user taps/clicks the calendar icon, the date picker opens up. Is there a way to customize the date picker to implement this functionality? Do I need a custom handler?

I tried calling Focus() on the date picker, but it didn't open up.

            <HorizontalStackLayout>
                <DatePicker x:Name="MyDatePicker" MinimumDate="01/01/2022"
                MaximumDate="12/31/2022"
                Date="06/21/2022" />
                <ImageButton Source="calendar.svg" Clicked="ImageButton_Clicked"></ImageButton>
            </HorizontalStackLayout>
    private void ImageButton_Clicked(object sender, EventArgs e)
    {
        MyDatePicker.Focus();
    }

Note: The icon is from: https://feathericons.com/?query=calendar


Solution

  • Utilizing Maui.FreakyControls and a custom behavior resulted in this solution for Android. It does require a custom handler to implement successfully.

    MauiProgram.cs

        public static MauiApp CreateMauiApp()
        {
            var builder = MauiApp.CreateBuilder();
            builder
                .UseMauiApp<App>()
                .ConfigureFonts(fonts =>
                {
                    fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                    fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
                })
                .ConfigureMauiHandlers(handlers =>
                {
                    handlers.AddCustomHandlers();
                });
    
            return builder.Build();
        }
    

    Shared/Extensions/Extensions.cs

    #if ANDROID
    using Microsoft.Maui.Controls.Compatibility.Platform.Android;
    using static Microsoft.Maui.ApplicationModel.Platform;
    using NativeImage = Android.Graphics.Bitmap;
    #endif
    
            public static void ExecuteCommandIfAvailable(this ICommand command, object parameter = null)
            {
                if (command?.CanExecute(parameter) == true)
                {
                    command.Execute(parameter);
                }
            }
    
            public static void AddCustomHandlers(this IMauiHandlersCollection handlers)
            {
    #if ANDROID
                handlers.AddHandler(typeof(Maui.CustomControls.CustomDatePicker), typeof(CustomDatePickerHandler));
    #endif
            }
    #if ANDROID
            public static async Task<NativeImage> ToNativeImageSourceAsync(this ImageSource source)
            {
                var handler = GetHandler(source);
                var returnValue = (NativeImage)null;
    
                returnValue = await handler.LoadImageAsync(source, CurrentActivity);
    
                return returnValue;
            }
    
            private static IImageSourceHandler GetHandler(this ImageSource source)
            {
                //Image source handler to return 
                IImageSourceHandler returnValue = null;
                //check the specific source type and return the correct image source handler 
                switch (source)
                {
                    case UriImageSource:
                        returnValue = new ImageLoaderSourceHandler();
                        break;
                    case FileImageSource:
                        returnValue = new FileImageSourceHandler();
                        break;
                    case StreamImageSource:
                        returnValue = new StreamImagesourceHandler();
                        break;
                    case FontImageSource:
                        returnValue = new FontImageSourceHandler();
                        break;
                }
                return returnValue;
            }
    #endif
    

    Android/NativeControls/Helpers/DrawableHandlerCallback.cs

        public class DrawableHandlerCallback : IDrawableClickListener
        {
            private readonly IDrawableImageView view;
            private readonly Action showPicker;
    
            public DrawableHandlerCallback(IDrawableImageView view, Action showPicker)
            {
                this.view = view;
                this.showPicker = showPicker;
            }
    
            public void OnClick(DrawablePosition target)
            {
                switch (target)
                {
                    case DrawablePosition.Left:
                    case DrawablePosition.Right:
                        view.ImageTappedHandler.Invoke(this.showPicker, null);
                        view.ImageCommand?.ExecuteCommandIfAvailable(view.ImageCommandParameter);
                        break;
                }
            }
        }
    

    Android/NativeControls/Helpers/DrawablePosition.cs

    public enum DrawablePosition
        {
            Top,
            Bottom,
            Left,
            Right
        };
    

    Android/NativeControls/Helpers/IDrawableClickListener.cs

    public interface IDrawableClickListener
        {
            public void OnClick(DrawablePosition target);
        }
    

    Android/NativeControls/CustomMauiDatePicker.cs

    public class CustomMauiDatePicker : MauiDatePicker
        {
            private Drawable drawableRight;
            private Drawable drawableLeft;
            private Drawable drawableTop;
            private Drawable drawableBottom;
    
            int actionX, actionY;
    
            private IDrawableClickListener clickListener;
    
            public CustomMauiDatePicker(Context context) : base(context)
            {
    
            }
    
            public CustomMauiDatePicker(Context context, IAttributeSet attrs) : base(context, attrs)
            {
    
            }
    
            public CustomMauiDatePicker(Context context, IAttributeSet attrs, int defStyleAttr) : base(context, attrs, defStyleAttr)
            {
    
            }
    
            public override void SetCompoundDrawablesWithIntrinsicBounds(Drawable left, Drawable top,
                   Drawable right, Drawable bottom)
            {
                if (left != null)
                {
                    drawableLeft = left;
                }
                if (right != null)
                {
                    drawableRight = right;
                }
                if (top != null)
                {
                    drawableTop = top;
                }
                if (bottom != null)
                {
                    drawableBottom = bottom;
                }
                base.SetCompoundDrawablesWithIntrinsicBounds(left, top, right, bottom);
            }
    
            public override bool OnTouchEvent(MotionEvent e)
            {
                Rect bounds;
                if (e.Action == MotionEventActions.Down)
                {
                    actionX = (int)e.GetX();
                    actionY = (int)e.GetY();
                    if (drawableBottom != null
                        && drawableBottom.Bounds.Contains(actionX, actionY))
                    {
                        clickListener.OnClick(DrawablePosition.Bottom);
                        return base.OnTouchEvent(e);
                    }
    
                    if (drawableTop != null
                            && drawableTop.Bounds.Contains(actionX, actionY))
                    {
                        clickListener.OnClick(DrawablePosition.Top);
                        return base.OnTouchEvent(e);
                    }
    
                    // this works for left since container shares 0,0 origin with bounds
                    if (drawableLeft != null)
                    {
                        bounds = null;
                        bounds = drawableLeft.Bounds;
    
                        int x, y;
                        int extraTapArea = (int)(13 * Resources.DisplayMetrics.Density + 0.5);
    
                        x = actionX;
                        y = actionY;
    
                        if (!bounds.Contains(actionX, actionY))
                        {
                            // Gives the +20 area for tapping. /
                            x = (int)(actionX - extraTapArea);
                            y = (int)(actionY - extraTapArea);
    
                            if (x <= 0)
                                x = actionX;
                            if (y <= 0)
                                y = actionY;
    
                            // Creates square from the smallest value /
                            if (x < y)
                            {
                                y = x;
                            }
                        }
    
                        if (bounds.Contains(x, y) && clickListener != null)
                        {
                            clickListener.OnClick(DrawablePosition.Left);
                            e.Action = (MotionEventActions.Cancel);
                            return false;
    
                        }
                    }
    
                    if (drawableRight != null)
                    {
    
                        bounds = null;
                        bounds = drawableRight.Bounds;
    
                        int x, y;
                        int extraTapArea = 13;
    
                        //
                        //  IF USER CLICKS JUST OUT SIDE THE RECTANGLE OF THE DRAWABLE
                        //  THAN ADD X AND SUBTRACT THE Y WITH SOME VALUE SO THAT AFTER
                        //  CALCULATING X AND Y CO-ORDINATE LIES INTO THE DRAWBABLE
                        //  BOUND. - this process help to increase the tappable area of
                        //  the rectangle.
                        // 
                        x = (int)(actionX + extraTapArea);
                        y = (int)(actionY - extraTapArea);
    
                        //Since this is right drawable subtract the value of x from the width 
                        // of view. so that width - tappedarea will result in x co-ordinate in drawable bound. 
                        //
                        x = Width - x;
    
                        //x can be negative if user taps at x co-ordinate just near the width.
                        // e.g views width = 300 and user taps 290. Then as per previous calculation
                        // 290 + 13 = 303. So subtract X from getWidth() will result in negative value.
                        // So to avoid this add the value previous added when x goes negative.
                        //
    
                        if (x <= 0)
                        {
                            x += extraTapArea;
                        }
    
                        // If result after calculating for extra tappable area is negative.
                        // assign the original value so that after subtracting
                        // extratapping area value doesn't go into negative value.
                        //
    
                        if (y <= 0)
                            y = actionY;
    
                        //If drawble bounds contains the x and y points then move ahead./
                        if (bounds.Contains(x, y) && clickListener != null)
                        {
                            clickListener
                                    .OnClick(DrawablePosition.Right);
                            e.Action = (MotionEventActions.Cancel);
                            return false;
                        }
                        return base.OnTouchEvent(e);
                    }
    
                }
                return base.OnTouchEvent(e);
            }
    
            protected override void JavaFinalize()
            {
                drawableRight = null;
                drawableBottom = null;
                drawableLeft = null;
                drawableTop = null;
                base.JavaFinalize();
            }
    
            public void SetDrawableClickListener(IDrawableClickListener listener)
            {
                this.clickListener = listener;
            }
        }
    

    Platforms/Android/CustomDatePickerHandler.android.cs

    public partial class CustomDatePickerHandler
        {
            DatePickerDialog? _dialog;
    
            protected override MauiDatePicker CreatePlatformView()
            {
                var mauiDatePicker = new CustomMauiDatePicker(Context)
                {
                    ShowPicker = ShowPickerDialog,
                    HidePicker = HidePickerDialog
                };
    
                var date = VirtualView?.Date;
    
                if (date != null)
                    _dialog = CreateDatePickerDialog(date.Value.Year, date.Value.Month, date.Value.Day);
                var colorStateList = ColorStateList.ValueOf(Android.Graphics.Color.Transparent);
                ViewCompat.SetBackgroundTintList(mauiDatePicker, colorStateList);
                return mauiDatePicker;
            }
    
            protected override void DisconnectHandler(MauiDatePicker platformView)
            {
                base.DisconnectHandler(platformView);
                if (_dialog != null)
                {
                    _dialog.Hide();
                    _dialog.Dispose();
                    _dialog = null;
                }
            }
    
            internal DatePickerDialog? DatePickerDialog { get { return _dialog; } }
    
            internal async Task HandleAndAlignImageSourceAsync(Maui.CustomControls.CustomDatePicker entry)
            {
                var imageBitmap = await entry.ImageSource?.ToNativeImageSourceAsync();
                if (imageBitmap != null)
                {
                    var bitmapDrawable = new BitmapDrawable(Platform.CurrentActivity?.Resources,
                        Bitmap.CreateScaledBitmap(imageBitmap, entry.ImageWidth * 2, entry.ImageHeight * 2, true));
                    var customDatePicker = PlatformView as CustomMauiDatePicker;
                    customDatePicker.SetDrawableClickListener(new DrawableHandlerCallback(entry, customDatePicker.ShowPicker));
                    switch (entry.ImageAlignment)
                    {
                        case ImageAlignment.Left:
                            customDatePicker.SetCompoundDrawablesWithIntrinsicBounds(bitmapDrawable, null, null, null);
                            break;
                        case ImageAlignment.Right:
                            customDatePicker.SetCompoundDrawablesWithIntrinsicBounds(null, null, bitmapDrawable, null);
                            break;
                    }
                }
                PlatformView.CompoundDrawablePadding = entry.ImagePadding;
            }
    
    
            void ShowPickerDialog()
            {
                if (VirtualView == null)
                    return;
    
                if (_dialog != null && _dialog.IsShowing)
                    return;
    
                var date = VirtualView.Date;
                ShowPickerDialog(date.Year, date.Month - 1, date.Day);
            }
    
            void ShowPickerDialog(int year, int month, int day)
            {
                if (_dialog == null)
                    _dialog = CreateDatePickerDialog(year, month, day);
                else
                {
                    EventHandler? setDateLater = null;
                    setDateLater = (sender, e) => { _dialog!.UpdateDate(year, month, day); _dialog.ShowEvent -= setDateLater; };
                    _dialog.ShowEvent += setDateLater;
                }
    
                _dialog.Show();
            }
    
            void HidePickerDialog()
            {
                _dialog?.Hide();
            }
        }
    

    Shared/Controls/IDrawableImageView.cs

    public interface IDrawableImageView
        {
            public EventHandler ImageTappedHandler
            {
                get;
            }
    
            public object ImageCommandParameter
            {
                get;
            }
    
            public ICommand ImageCommand
            {
                get;
            }
    
            public int ImagePadding
            {
                get;
            }
    
            public int ImageWidth
            {
                get;
            }
    
            public int ImageHeight
            {
                get;
            }
    
            public ImageSource ImageSource
            {
                get;
            }
    
            public ImageAlignment ImageAlignment
            {
                get;
            }
        }
    

    Shared/CustomDatePicker/CustomDatePicker.cs

    public class CustomDatePicker : DatePicker, IDrawableImageView
        {
    
            private EventHandler imageTapped;
            public EventHandler ImageTappedHandler
            {
                get { return imageTapped; }
            }
            public event EventHandler ImageTapped
            {
                add
                {
                    imageTapped += value;
                }
                remove
                {
                    imageTapped -= value;
                }
            }
    
            public static readonly BindableProperty ImageSourceProperty = BindableProperty.Create(
                    nameof(Image),
                    typeof(ImageSource),
                    typeof(CustomDatePicker),
                    default(ImageSource));
    
            public static readonly BindableProperty ImageHeightProperty = BindableProperty.Create(
                   nameof(ImageHeight),
                   typeof(int),
                   typeof(CustomDatePicker),
                   25);
    
            public static readonly BindableProperty ImageWidthProperty = BindableProperty.Create(
                   nameof(ImageWidth),
                   typeof(int),
                   typeof(CustomDatePicker),
                   25);
    
            public static readonly BindableProperty ImageAlignmentProperty = BindableProperty.Create(
                   nameof(ImageAlignment),
                   typeof(ImageAlignment),
                   typeof(CustomDatePicker),
                   ImageAlignment.Right);
    
            public static readonly BindableProperty ImagePaddingProperty = BindableProperty.Create(
                   nameof(ImagePadding),
                   typeof(int),
                   typeof(CustomDatePicker),
                   5);
    
            public static readonly BindableProperty ImageCommandProperty = BindableProperty.Create(
                  nameof(ImagePadding),
                  typeof(ICommand),
                  typeof(CustomDatePicker),
                  default(ICommand));
    
            public static readonly BindableProperty ImageCommandParameterProperty = BindableProperty.Create(
                  nameof(ImageCommandParameter),
                  typeof(object),
                  typeof(CustomDatePicker),
                  default(object));
    
            /// <summary>
            /// Command parameter for your Image tap command 
            /// </summary>
            public object ImageCommandParameter
            {
                get => GetValue(ImageCommandParameterProperty);
                set => SetValue(ImageCommandParameterProperty, value);
            }
    
            /// <summary>
            /// <see cref="ImageCommand"/> of type <see cref="ICommand"/> that you can use to bind with your Image that you added to your control's ViewPort
            /// </summary>
            public ICommand ImageCommand
            {
                get => (ICommand)GetValue(ImageCommandProperty);
                set => SetValue(ImageCommandProperty, value);
            }
    
            /// <summary>
            /// Padding of the Image as <see cref="int"/> that you added to the ViewPort
            /// </summary>
            public int ImagePadding
            {
                get => (int)GetValue(ImagePaddingProperty);
                set => SetValue(ImagePaddingProperty, value);
            }
    
            /// <summary>
            /// Width of the Image in your ViewPort
            /// </summary>
            public int ImageWidth
            {
                get => (int)GetValue(ImageWidthProperty);
                set => SetValue(ImageWidthProperty, value);
            }
    
            /// <summary>
            /// Height of the Image in your ViewPort
            /// </summary>
            public int ImageHeight
            {
                get => (int)GetValue(ImageHeightProperty);
                set => SetValue(ImageHeightProperty, value);
            }
    
            /// <summary>
            /// An <see cref="ImageSource"/> that you want to add to your ViewPort
            /// </summary>
            public ImageSource ImageSource
            {
                get => (ImageSource)GetValue(ImageSourceProperty);
                set => SetValue(ImageSourceProperty, value);
            }
    
            /// <summary>
            /// <see cref="ImageAlignment"/> for your Image's ViewPort, By default set to Right.
            /// </summary>
            public ImageAlignment ImageAlignment
            {
                get => (ImageAlignment)GetValue(ImageAlignmentProperty);
                set => SetValue(ImageAlignmentProperty, value);
            }
        }
    

    Shared/CustomDatePickerHandler/CustomDatePickerHandler.cs

    #if ANDROID
        public partial class CustomDatePickerHandler : DatePickerHandler
        {
            public CustomDatePickerHandler()
            {
                Mapper.AppendToMapping("CustomDatePickerCustomization", MapDatePicker);
            }
    
            private void MapDatePicker(IDatePickerHandler datePickerHandler, IDatePicker datePicker)
            {
                if (datePicker is Maui.CustomControls.CustomDatePicker customDatePicker &&
                    datePickerHandler is CustomDatePickerHandler customDatePickerHandler)
                {
                    if (customDatePicker.ImageSource != default(ImageSource))
                    {
                        customDatePickerHandler.HandleAndAlignImageSourceAsync(customDatePicker).RunConcurrently();
                    }
                }
            }
        }
    
    #endif
    

    Shared/Enums/ImageAlignment.cs

    public enum ImageAlignment
        {
            /// <summary>
            /// Aligns your control to the left view port of the view. 
            /// </summary>
            Left,
    
            /// <summary>
            /// Aligns your control to the right view port of the view.
            /// </summary>
            Right
        }
    

    Shared/Extensions/TaskExtensions.cs

    public static class TaskExtensions
        {
            public static void RunConcurrently(this Task task)
            {
                if (task == null)
                    throw new ArgumentNullException("task", "task is null.");
    
                if (task.Status == TaskStatus.Created)
                    task.Start();
            }
        }
    

    CustomDatePickerPage.xaml

    <StackLayout Padding="10,60,10,0">
            <Label Text="Please enter a date."
                   FontSize="12" />
            <custom:CustomDatePicker MinimumDate="01/01/2022"
                                             MaximumDate="12/31/2022"
                                             Date="06/21/2022"
                                             ImageSource="calendar"
                                             ImageAlignment="Right"
                                             ImageHeight="40"
                                             ImageWidth="40"
                                             ImagePadding="10"
                                             FontSize="Large">
                <custom:CustomDatePicker.Behaviors>
                    <local:CustomDatePickerBehavior />
                </custom:CustomDatePicker.Behaviors>
            </custom:CustomDatePicker>
        </StackLayout>
    

    Behaviors/CustomDatePickerBehavior.cs

    public class CustomDatePickerBehavior : Behavior<CustomDatePicker>
        {
            protected override void OnAttachedTo(CustomDatePicker picker)
            {
                picker.ImageTapped += Picker_ImageTapped;
                base.OnAttachedTo(picker);
            }
    
            protected override void OnDetachingFrom(CustomDatePicker picker)
            {
                picker.ImageTapped -= Picker_ImageTapped;
                base.OnDetachingFrom(picker);
            }
    
            private void Picker_ImageTapped(object sender, EventArgs e)
            {
                Action showPicker = (Action)sender;
                showPicker.Invoke();
            }
        }