1

I am adding color themes to a Xamarin Forms app. The theme is spec'ed to change dynamically depending on which client's location the user is standing at/near. We have both "light" and "dark" themed clients, having a variety of color palettes; so it needs to be possible to change all colors of all display items. Dynamically.

So far, my dynamic theming works for almost all types of views.

I'm stuck on two details when coloring the iOS-style (vertical scrolling wheel) Xamarin.Forms.Picker on Android:

Question 1: How change the background color?
Question 2: How change the "Cancel" button's text color?

This Android NumberPicker-based XF View, doesn't seem to be fully color-customizable out of the box. As of latest XF, 4.3.0.991211.


----- What I've tried:-----


A. Set Android theme colors statically, in Style.xml resource file. Success. But its static.


B. Download Xamarin Forms Light/Dark Theme Sample.

Build and run on Android. Go to its "Select Theme" picker. Pick light theme. Go to picker again - white background, so far, so good. Pick dark theme. Go to picker again - the picker is still black-on-white. No solution here.


C. Found online how to dynamically change AlertDialogs "OK"/"Cancel" button text colors, using an AlertDialog "builder", and getting at the resource IDs. Successfully implemented that. Not sure how to apply the same technique to Picker.

Question 3: How discover what resource IDs are used in the Android Control used by XF Picker?


D. Clone Xamarin Forms source code. Copy Droid PickerRenderer into my code. Most of the code can't be run because it depends on internal classes/functions. Make mine inherit from the built-in one, and comment out any code that isn't needed / won't compile.

Customize OnElementChanged, because that is where TextColor is set.

In the custom renderer source code, IPickerRenderer.OnClick() builds an AlertDialog containing a NumberPicker. I don't see any logic that sets Background color or "Cancel" button's text color. But at least we have known Android classes. See Question 3 above.


E. At a minimum, having a small number of predefined themes might be a solution. Or Android theme.ApplyStyle - but can this be a dynamic style? I haven't yet tested to see if either of these successfully changes the details that I am stuck on. [Will report back after I try.]


F. It might be possible by restarting the app to change theme: any-color themes. This would be my last resort; first seeing if I can control colors completely from within C#, without a restart.


NOTE: There are many posts at SO and Xamarin.Forums that talk generally about theming in Xamarin Forms. I'm past that point, so I won't list those here, except to mention Clint St.Laurent's theming post, which (taking a quick glance at it) looks similar to what I've done. All I have left are a small number of tiny details - which Xamarin Forms fails to apply theme colors to, in my testing.


G. Other SO posts that seem related - but don't do the exact tasks listed in Questions 1 & 2:

Picker implemented as an AlertDialog. Shows a custom PickerRenderer. But doesn't show how to set the colors in Q 1 & 2.

Another Picker implemented as an AlertDialog. Likewise.

How to really change primary and accent color in android lollipop - I mention answers from this in E and F. If I can't achieve the result via C# code in custom PickerRenderer, I will investigate these.


Xamarin Forums posts:

How to change the colorAccent at runtime?. The only answer given doesn't change theme's colorAccent. It just changes the app bar's color.


CLARIFICATION: There are plenty of doc, forum, and blog posts that describe the general mechanism of Xamarin Forms theming. Not looking for answers to that. I'm just looking to resolve a small number of missing details, as of latest stable Xamarin Forms, 4.3.0.991211. Hopefully the day will come when Xamarin Forms successfully themes 100% of the details of all types of controls - at which time this Question and Answer will be obsolete.

ToolmakerSteve
  • 18,547
  • 14
  • 94
  • 196

1 Answers1

0

An implementation of D. in the question. Credits given as comments in the code.

This custom Android PickerRenderer dynamically sets 3 colors: text (blue), background behind the "wheel" of texts (light orange), buttons (dark red).

Picker with dynamic blue text, light-orange background, dark red accent color.

Clarification: the static Android theme has a dark background. The goal is to dynamically change from that dark theme to a light theme. To make it very obvious what colors have been successfully set, strongly tinted colors are used.

The white "dividers" between the texts I haven't colored yet. I've seen mention in either Xamarin or Android docs of a divider drawable. It should be straightforward to get hold of that, and set its color. EDIT: Maybe titleDivider in Tiago Flores code snippet in Change picker control pop-up background and text colors, or in Vahid's answer. OR selectionDividerField in Tapa Save's answer.

NOTE: I haven't added a custom XF property to control the button color; hard-coded to Color.Accent.

using Android.App;
using Android.Util;
using Android.Views;
using Android.Widget;
using System;
using System.ComponentModel;
using System.Linq;
using Orientation = Android.Widget.Orientation;
using Android.Content;
using Android.Text;
using Android.Text.Style;
using Java.Lang;

// PickerEditText
using Xamarin.Forms.Platform.Android;

using AColor = Android.Graphics.Color;
using Picker = Xamarin.Forms.Picker;

[assembly: Xamarin.Forms.ExportRenderer(typeof(Picker), typeof(LCPickerRenderer))]
namespace Xamarin.Forms.Platform.Android
{
    // Based on Xamarin.Forms source code, modified.
    public class LCPickerRenderer : PickerRenderer, IPickerRenderer   //ViewRenderer<Picker, EditText>, IPickerRenderer
    {
        AlertDialog _dialog;
        NumberPicker _picker;

        public LCPickerRenderer(Context context) : base(context)
        {
            //AutoPackage = false;
        }

        [Obsolete("This constructor is obsolete as of version 2.5. Please use PickerRenderer(Context) instead.")]
        [EditorBrowsable(EditorBrowsableState.Never)]
        public LCPickerRenderer()
        {
            //AutoPackage = false;
        }

        IElementController ElementController => Element as IElementController;

        protected override void OnElementChanged(ElementChangedEventArgs<Picker> e)
        {
            if (e.OldElement != null) {
                //((INotifyCollectionChanged)e.OldElement.Items).CollectionChanged -= RowsCollectionChanged;
            }

            base.OnElementChanged(e);

            if (e.NewElement != null) {
                // --- custom work ---
                if (Control != null) {
                    SetTextColor();
                    SetBackgroundColor();
                }
            }
        }

        protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            base.OnElementPropertyChanged(sender, e);

            if (e.PropertyName == Picker.BackgroundColorProperty.PropertyName) {
                SetBackgroundColor();
            } else if (e.PropertyName == Picker.TextColorProperty.PropertyName) {
                SetTextColor();
            }

            //if (e.PropertyName == Picker.TitleProperty.PropertyName || e.PropertyName == Picker.TitleColorProperty.PropertyName)
            //    UpdatePicker();
            //else if (e.PropertyName == Picker.SelectedIndexProperty.PropertyName)
            //    UpdatePicker();
            //else if (e.PropertyName == Picker.CharacterSpacingProperty.PropertyName)
            //    UpdateCharacterSpacing();
            //else if (e.PropertyName == Picker.TextColorProperty.PropertyName)
            //    UpdateTextColor();
            //else if (e.PropertyName == Picker.FontAttributesProperty.PropertyName || e.PropertyName == Picker.FontFamilyProperty.PropertyName || e.PropertyName == Picker.FontSizeProperty.PropertyName)
            //    UpdateFont();
        }


        void IPickerRenderer.OnClick()
        {
            Picker model = Element;

            if (_dialog != null)
                return;

            var xfTextColor = Element.TextColor;
            _picker = new NumberPicker(Context);
            if (model.Items != null && model.Items.Any()) {
                _picker.MaxValue = model.Items.Count - 1;
                _picker.MinValue = 0;
                _picker.SetDisplayedValues(model.Items.ToArray());
                _picker.WrapSelectorWheel = false;
                _picker.DescendantFocusability = DescendantFocusability.BlockDescendants;
                _picker.Value = model.SelectedIndex;
            }

            var layout = new LinearLayout(Context) { Orientation = Orientation.Vertical };
            layout.AddView(_picker);

            ElementController.SetValueFromRenderer(VisualElement.IsFocusedPropertyKey, true);

            var builder = new AlertDialog.Builder(Context);
            builder.SetView(layout);

            if (!Element.IsSet(Picker.TitleColorProperty)) {
                builder.SetTitle(model.Title ?? "");
            } else {
                var title = new SpannableString(model.Title ?? "");
                title.SetSpan(new ForegroundColorSpan(model.TitleColor.ToAndroid()), 0, title.Length(), SpanTypes.ExclusiveExclusive);

                builder.SetTitle(title);
            }

            // TODO: get texts (and whether to show) from XF Element.
            builder.SetNegativeButton(global::Android.Resource.String.Cancel, (s, a) => {
                ElementController.SetValueFromRenderer(VisualElement.IsFocusedPropertyKey, false);
                _dialog = null;
            });
            builder.SetPositiveButton(global::Android.Resource.String.Ok, (s, a) => {
                ElementController.SetValueFromRenderer(Picker.SelectedIndexProperty, _picker.Value);
                // It is possible for the Content of the Page to be changed on SelectedIndexChanged. 
                // In this case, the Element & Control will no longer exist.
                if (Element != null) {
                    if (model.Items.Count > 0 && Element.SelectedIndex >= 0)
                        Control.Text = model.Items[Element.SelectedIndex];
                    ElementController.SetValueFromRenderer(VisualElement.IsFocusedPropertyKey, false);
                }
                _dialog = null;
            });

            _dialog = builder.Create();
            _dialog.DismissEvent += (sender, args) => {
                ElementController?.SetValueFromRenderer(VisualElement.IsFocusedPropertyKey, false);
                _dialog?.Dispose();
                _dialog = null;
            };
            _dialog.Show();

            // TODO: Control this via a custom XF property.
            var buttonColor = Color.Accent.ToAndroid();
            // From https://stackoverflow.com/a/56555965/199364.
            var btnCancel = _dialog.GetButton((int)DialogInterface.ButtonNegative);
            // "?" in case no such button was attached.
            btnCancel?.SetTextColor(buttonColor);
            var btnOK = _dialog.GetButton((int)DialogInterface.ButtonPositive);
            btnOK?.SetTextColor(buttonColor);

            SetTextColor();
            SetBackgroundColor();
        }

        private void SetTextColor()
        {
            Control?.SetTextColor(Element.TextColor.ToAndroid());
            if (_picker != null)
                SetTextColor(_picker, Element.TextColor.ToAndroid());
        }

        private void SetBackgroundColor()
        {
            Control?.SetBackgroundColor(Element.BackgroundColor.ToAndroid());
            _picker?.SetBackgroundColor(Element.BackgroundColor.ToAndroid());
            _dialog?.Window.SetBackgroundDrawable(new global::Android.Graphics.Drawables.ColorDrawable(Element.BackgroundColor.ToAndroid()));
        }

        // From https://stackoverflow.com/a/26657169/199364
        // TODO: When support API 29, this needs to be modified re https://stackoverflow.com/a/56883356/199364 ?
        public static bool SetTextColor(NumberPicker numberPicker, AColor color)
        {
            var sdkInt = global::Android.OS.Build.VERSION.SdkInt;
            //var limitSdk = global::Android.OS.Build.VERSION_CODES.Q;

            int count = numberPicker.ChildCount;
            for (int i = 0; i < count; i++) {
                var child = numberPicker.GetChildAt(i);
                if (child.GetType() == typeof(EditText)) {
                    try {
                        var selectorWheelPaintField = numberPicker.Class
                                                                    .GetDeclaredField("mSelectorWheelPaint");
                        selectorWheelPaintField.Accessible = true;

                        EditText editText = (EditText)child;
                        editText.SetTextColor(color);

                        var paint = (global::Android.Graphics.Paint)selectorWheelPaintField.Get(numberPicker);
                        paint.Color = color;

                        numberPicker.Invalidate();
                        return true;
                    }
                    catch (NoSuchFieldException e) {
                        Log.Warn("setNumberPickerTextColor", e);
                    }
                    catch (IllegalAccessException e) {
                        Log.Warn("setNumberPickerTextColor", e);
                    }
                    catch (IllegalArgumentException e) {
                        Log.Warn("setNumberPickerTextColor", e);
                    }
                }
            }
            return false;
        }
    }
}

BONUS: Style an Android AlertDialog:

AlertDialog with different colors for title, message, each button, background

Most dialogs can be built on Android (in your Android project code) using AlertDialog.Builder. This can be done without making a custom Renderer. In this code, I use variously named colors from my XF Application's Resources. Google techniques for color theming in Xamarin Form, to see how to set such resources to different color values:

// Extracted from a larger class. Some of these not used in the code here.
using System;
using System.Collections.Generic;
using System.IO;

using Android.Content;
using Android.Content.PM;
using Android.Graphics;
using Android.Provider;
using Android.Views;
using Android.Widget;

using Android.Support.V4.Content;
using Android.Support.V4.App;
using Android.Support.V7.App;

// For Extension: color.ToAndroid.
using Xamarin.Forms.Platform.Android;

public static partial class UI
{

    // If cancelHandler is null, no Cancel button will be added.
    // If noHandler is not null, then "OK" is renamed "Yes".
    public static void ShowAlertDialog(string title, string message,
                                        EventHandler okHandler, EventHandler cancelHandler,
                                        EventHandler noHandler = null, bool textIsYes = false)
    {
        bool hasCancel = (cancelHandler != null);
        bool hasNo = (noHandler != null);
        string okOrYes = PS.LocalizedString((textIsYes || hasNo ? "Yes" : "OK"));
        // When both cancel and no, is "Cancel".
        // When neither cancel or no, defaults to "Cancel", which does nothing.
        // The only time this is "No", is if there is a No button, but no Cancel button.
        string cancelOrNo = PS.LocalizedString((hasNo && !hasCancel ? "No" : "Cancel"));
        EventHandler cancelOrNoHandler = (hasCancel ? cancelHandler : noHandler);

        AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.it);

        var titleOb = new Android.Text.SpannableString(title);
        // TODO: Add custom XF property to set this.
        var titleColor = (Xamarin.Forms.Color)Xamarin.Forms.Application.Current.Resources["MaxForegroundColor"];
        titleOb.SetSpan(new Android.Text.Style.ForegroundColorSpan(titleColor.ToAndroid()), 0, title.Length, Android.Text.SpanTypes.ExclusiveExclusive);
        builder.SetTitle(titleOb);

        builder.SetMessage(message);

        if (hasCancel || hasNo) {
            builder.SetNegativeButton(cancelOrNo,
                                 (s, e) => {
                                     cancelOrNoHandler?.Invoke(null, EventArgs.Empty);
                                 });
        }

        // Only used when BOTH Cancel and No buttons.
        if (hasNo && hasCancel)
            builder.SetNeutralButton(PS.LocalizedString("No"),
                                (s, e) => { noHandler.Invoke(null, EventArgs.Empty); });

        builder.SetPositiveButton(okOrYes, (s, e) => {
            if (okHandler != null)
                okHandler.Invoke(null, EventArgs.Empty);
        });

        AlertDialog dialog = builder.Show();
        SetAlertStyle(dialog);
    }

    /// <summary>
    /// This doesn't set Title Color. Set that earlier, while building the dialog.
    /// </summary>
    /// <param name="dialog"></param>
    private static void SetAlertStyle(AlertDialog dialog)
    {
        // Use these if succeed in changing both title and background colors.
        var foreColor = (Xamarin.Forms.Color)Xamarin.Forms.Application.Current.Resources["ForegroundColor"];
        var accentAgainstBack = (Xamarin.Forms.Color)Xamarin.Forms.Application.Current.Resources["AccentSaturated25Fore33"];

        var backColor = (Xamarin.Forms.Color)Xamarin.Forms.Application.Current.Resources["BackgroundColor"];
        SetBackgroundColor(dialog, backColor);

        //// DIDN'T WORK: No effect on title color. Set earlier, while building the dialog.
        //int alertViewId = Android.App.Application.Context.Resources.GetIdentifier("alertTitle", "id", "android");
        //SetItemTextColor(dialog, "alertTitle", accentAgainstBack);

        SetItemTextColor(dialog, "message", foreColor);

        SetItemTextColor(dialog, "button1", (Xamarin.Forms.Color)Xamarin.Forms.Application.Current.Resources["AccentSaturated25Fore33"]);
        SetItemTextColor(dialog, "button2", (Xamarin.Forms.Color)Xamarin.Forms.Application.Current.Resources["ForegroundColor"]);
    }

    private static void SetItemTextColor(AlertDialog dialog, string itemResourceName, Xamarin.Forms.Color color)
    {
        int textId = Android.App.Application.Context.Resources.GetIdentifier(itemResourceName, "id", "android");
        TextView text = dialog.FindViewById<TextView>(textId);
        text?.SetTextColor(color.ToAndroid());
    }

    private static void SetBackgroundColor(AlertDialog dialog, Xamarin.Forms.Color xfColor)
    {
        dialog?.Window.SetBackgroundDrawable(new global::Android.Graphics.Drawables.ColorDrawable(xfColor.ToAndroid()));
    }

}
ToolmakerSteve
  • 18,547
  • 14
  • 94
  • 196