[Material] [Android, iOS] Materializing the stepper (#5027)
authorMatthew Leibowitz <mattleibow@live.com>
Fri, 22 Feb 2019 19:51:45 +0000 (21:51 +0200)
committerShane Neuville <shane94@hotmail.com>
Fri, 22 Feb 2019 19:51:45 +0000 (12:51 -0700)
* [Android] [Material] Refactored the Stepper on Android #5011
 - reuse code between default and material
 - added material stepper

* [Android] [Material] Using the "Fast" renderer style for the stepper #5011
 - switched the button style to the outlined buttons

* [Android] [Material] Adding the tab stops support (#5000)

* [Android] [Material] Switching back to a traditional renderer for now to get tabstops working

* [Material] [iOS] Added a Material stepper for iOS

* [Material] [Android, iOS] Addressed the PR feedback

* [Material] Remove private

* Update MaterialStepperRenderer.cs

* Update StepperRendererManager.cs

* Fixed the merge issues

* add steppers to visual gallery

* The material stepper buttons now resize to fit the available space.

* Update Xamarin.Forms.Controls/ControlGalleryPages/VisualGallery.xaml

Co-Authored-By: mattleibow <mattleibow@live.com>
* [Material] [Stepper] Don't set the background color for steppers
 - we still need to decide on the logic behind the coloring of outline buttons

* Fixed the Android accessibility

* Change UI tests to not use a Text search for locating stepper controls

Xamarin.Forms.Controls.Issues/Xamarin.Forms.Controls.Issues.Shared/Issue2597.cs
Xamarin.Forms.Controls/ControlGalleryPages/VisualGallery.xaml
Xamarin.Forms.Material.iOS/MaterialStepperRenderer.cs [new file with mode: 0644]
Xamarin.Forms.Material.iOS/Xamarin.Forms.Material.iOS.csproj
Xamarin.Forms.Platform.Android/IStepperRenderer.cs [new file with mode: 0644]
Xamarin.Forms.Platform.Android/Material/MaterialStepperRenderer.cs [new file with mode: 0644]
Xamarin.Forms.Platform.Android/Renderers/StepperRenderer.cs
Xamarin.Forms.Platform.Android/Resources/values/styles.xml
Xamarin.Forms.Platform.Android/StepperRendererManager.cs [new file with mode: 0644]
Xamarin.Forms.Platform.Android/Xamarin.Forms.Platform.Android.csproj

index 1a737f4..bb87449 100644 (file)
@@ -70,8 +70,18 @@ namespace Xamarin.Forms.Controls.Issues
 #if __IOS__
                        RunningApp.Tap(x => x.Marked("Increment"));
 #else
-                       RunningApp.Tap(x => x.Text("+"));
+                       RunningApp.Tap("+");
 #endif
+
+                       RunningApp.WaitForElement(q => q.Marked("Stepper value is 0"));
+
+
+#if __IOS__
+                       RunningApp.Tap(x => x.Marked("Decrement"));
+#else
+                       RunningApp.Tap("−");
+#endif
+
                        RunningApp.WaitForElement(q => q.Marked("Stepper value is 0"));
                }
 #endif
index 0c15494..e644fc5 100644 (file)
             <Label Text="Custom (Disabled)" Margin="0,0,0,-10" />
             <Slider Minimum="-100" Maximum="100" IsEnabled="false"
                     ThumbColor="{StaticResource DarkRedColor}" MaximumTrackColor="{StaticResource SecondaryColor}" MinimumTrackColor="{StaticResource PrimaryColor}" />
-
+            
+            <Label Text="Steppers" FontSize="Large" />
+            <Label Text="Default" Margin="0,0,0,-10" />
+            <Stepper HorizontalOptions="Start" />
+
+            <Label Text="Height 100" Margin="0,0,0,-10" />
+            <Stepper HeightRequest="100"></Stepper>
+            
+            <Label Text="Red background" Margin="0,0,0,-10" />
+            <Stepper BackgroundColor="Red"></Stepper>
+            
         </StackLayout>
     </ScrollView>
 
-</ContentPage>
\ No newline at end of file
+</ContentPage>
diff --git a/Xamarin.Forms.Material.iOS/MaterialStepperRenderer.cs b/Xamarin.Forms.Material.iOS/MaterialStepperRenderer.cs
new file mode 100644 (file)
index 0000000..2839737
--- /dev/null
@@ -0,0 +1,154 @@
+using System;
+using System.ComponentModel;
+using CoreGraphics;
+using MaterialComponents;
+using UIKit;
+using Xamarin.Forms;
+using MButton = MaterialComponents.Button;
+
+[assembly: ExportRenderer(typeof(Xamarin.Forms.Stepper), typeof(Xamarin.Forms.Platform.iOS.Material.MaterialStepperRenderer), new[] { typeof(VisualRendererMarker.Material) })]
+
+namespace Xamarin.Forms.Platform.iOS.Material
+{
+       public class MaterialStepperRenderer : ViewRenderer<Stepper, MaterialStepper>
+       {
+               ButtonScheme _buttonScheme;
+
+               public MaterialStepperRenderer()
+               {
+                       VisualElement.VerifyVisualFlagEnabled();
+               }
+
+               protected override void Dispose(bool disposing)
+               {
+                       if (Control is MaterialStepper control)
+                       {
+                               control.DecrementButton.TouchUpInside -= OnStep;
+                               control.IncrementButton.TouchUpInside -= OnStep;
+                       }
+
+                       _buttonScheme?.Dispose();
+                       _buttonScheme = null;
+
+                       base.Dispose(disposing);
+               }
+
+               protected override void OnElementChanged(ElementChangedEventArgs<Stepper> e)
+               {
+                       _buttonScheme?.Dispose();
+                       _buttonScheme = CreateButtonScheme();
+
+                       base.OnElementChanged(e);
+
+                       if (e.NewElement != null)
+                       {
+                               if (Control == null)
+                               {
+                                       var stepper = CreateNativeControl();
+                                       stepper.DecrementButton.TouchUpInside += OnStep;
+                                       stepper.IncrementButton.TouchUpInside += OnStep;
+                                       SetNativeControl(stepper);
+                               }
+
+                               UpdateButtons();
+                               ApplyTheme();
+                       }
+               }
+
+               protected virtual ButtonScheme CreateButtonScheme()
+               {
+                       return new ButtonScheme
+                       {
+                               ColorScheme = MaterialColors.Light.CreateColorScheme(),
+                               ShapeScheme = new ShapeScheme(),
+                               TypographyScheme = new TypographyScheme(),
+                       };
+               }
+
+               protected virtual void ApplyTheme()
+               {
+                       OutlinedButtonThemer.ApplyScheme(_buttonScheme, Control.DecrementButton);
+                       OutlinedButtonThemer.ApplyScheme(_buttonScheme, Control.IncrementButton);
+               }
+
+               protected override MaterialStepper CreateNativeControl()
+               {
+                       return new MaterialStepper();
+               }
+
+               protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
+               {
+                       base.OnElementPropertyChanged(sender, e);
+
+                       if (e.IsOneOf(Stepper.MinimumProperty, Stepper.MaximumProperty, Stepper.ValueProperty, VisualElement.IsEnabledProperty))
+                       {
+                               UpdateButtons();
+                       }
+               }
+
+               protected override void SetBackgroundColor(Color color)
+               {
+                       // don't call base
+               }
+
+               void UpdateButtons()
+               {
+                       if (Element is Stepper stepper && Control is MaterialStepper control)
+                       {
+                               control.DecrementButton.Enabled = stepper.IsEnabled && stepper.Value > stepper.Minimum;
+                               control.IncrementButton.Enabled = stepper.IsEnabled && stepper.Value < stepper.Maximum;
+                       }
+               }
+
+               void OnStep(object sender, EventArgs e)
+               {
+                       if (Element is Stepper stepper && sender is MButton button)
+                       {
+                               var increment = stepper.Increment;
+                               if (button == Control.DecrementButton)
+                                       increment = -increment;
+
+                               stepper.SetValueFromRenderer(Stepper.ValueProperty, stepper.Value + increment);
+                       }
+               }
+       }
+
+       public class MaterialStepper : UIView
+       {
+               const int DefaultButtonSpacing = 4;
+
+               public MaterialStepper()
+               {
+                       DecrementButton = new MButton();
+                       DecrementButton.SetTitle("-", UIControlState.Normal);
+
+                       IncrementButton = new MButton();
+                       IncrementButton.SetTitle("+", UIControlState.Normal);
+
+                       AddSubviews(DecrementButton, IncrementButton);
+               }
+
+               public MButton DecrementButton { get; }
+
+               public MButton IncrementButton { get; }
+
+               public override CGSize SizeThatFits(CGSize size)
+               {
+                       var dec = DecrementButton.SizeThatFits(CGSize.Empty);
+                       var inc = IncrementButton.SizeThatFits(CGSize.Empty);
+                       var btn = new CGSize(
+                               Math.Max(dec.Width, inc.Width),
+                               Math.Max(dec.Height, inc.Height));
+                       return new CGSize(btn.Width * 2 + DefaultButtonSpacing, btn.Height);
+               }
+
+               public override void LayoutSubviews()
+               {
+                       base.LayoutSubviews();
+
+                       var btn = new CGSize((Bounds.Width - DefaultButtonSpacing) / 2, Bounds.Height);
+                       DecrementButton.Frame = new CGRect(0, 0, btn.Width, btn.Height);
+                       IncrementButton.Frame = new CGRect(btn.Width + DefaultButtonSpacing, 0, btn.Width, btn.Height);
+               }
+       }
+}
index 220f296..6160b9e 100644 (file)
@@ -66,6 +66,7 @@
     <Compile Include="MaterialFrameRenderer.cs" />
     <Compile Include="MaterialProgressBarRenderer.cs" />
     <Compile Include="MaterialSliderRenderer.cs" />
+    <Compile Include="MaterialStepperRenderer.cs" />
     <Compile Include="FormsMaterial.cs" />
   </ItemGroup>
   <ItemGroup>
diff --git a/Xamarin.Forms.Platform.Android/IStepperRenderer.cs b/Xamarin.Forms.Platform.Android/IStepperRenderer.cs
new file mode 100644 (file)
index 0000000..9ed3b7d
--- /dev/null
@@ -0,0 +1,15 @@
+using AButton = Android.Widget.Button;
+
+namespace Xamarin.Forms.Platform.Android
+{
+       public interface IStepperRenderer
+       {
+               Stepper Element { get; }
+
+               AButton UpButton { get; }
+
+               AButton DownButton { get; }
+
+               AButton CreateButton();
+       }
+}
diff --git a/Xamarin.Forms.Platform.Android/Material/MaterialStepperRenderer.cs b/Xamarin.Forms.Platform.Android/Material/MaterialStepperRenderer.cs
new file mode 100644 (file)
index 0000000..e396f1d
--- /dev/null
@@ -0,0 +1,101 @@
+#if __ANDROID_28__
+using System.ComponentModel;
+using Android.Content;
+using Android.Views;
+using Android.Widget;
+using Xamarin.Forms;
+using Xamarin.Forms.Platform.Android.Material;
+using AButton = Android.Widget.Button;
+using MButton = Android.Support.Design.Button.MaterialButton;
+
+[assembly: ExportRenderer(typeof(Xamarin.Forms.Stepper), typeof(MaterialStepperRenderer), new[] { typeof(VisualRendererMarker.Material) })]
+
+namespace Xamarin.Forms.Platform.Android.Material
+{
+       public class MaterialStepperRenderer : ViewRenderer<Stepper, LinearLayout>, IStepperRenderer
+       {
+               const int DefaultButtonSpacing = 4;
+
+               MButton _downButton;
+               MButton _upButton;
+
+               public MaterialStepperRenderer(Context context) : base(context)
+               {
+                       VisualElement.VerifyVisualFlagEnabled();
+
+                       AutoPackage = false;
+               }
+
+               protected override LinearLayout CreateNativeControl()
+               {
+                       return new LinearLayout(Context)
+                       {
+                               Orientation = Orientation.Horizontal,
+                               Focusable = true,
+                               DescendantFocusability = DescendantFocusability.AfterDescendants
+                       };
+               }
+
+               protected override void OnElementChanged(ElementChangedEventArgs<Stepper> e)
+               {
+                       base.OnElementChanged(e);
+
+                       if (e.OldElement == null)
+                       {
+                               if (Control == null)
+                               {
+                                       var layout = CreateNativeControl();
+                                       StepperRendererManager.CreateStepperButtons(this, out _downButton, out _upButton);
+                                       layout.AddView(_downButton, new LinearLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.MatchParent)
+                                       {
+                                               Weight = 1,
+                                               RightMargin = (int)(Context.ToPixels(DefaultButtonSpacing) / 2),
+                                       });
+                                       layout.AddView(_upButton, new LinearLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.MatchParent)
+                                       {
+                                               Weight = 1,
+                                               LeftMargin = (int)(Context.ToPixels(DefaultButtonSpacing) / 2),
+                                       });
+
+                                       SetNativeControl(layout);
+                               }
+                       }
+
+                       StepperRendererManager.UpdateButtons(this, _downButton, _upButton);
+               }
+
+               protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
+               {
+                       base.OnElementPropertyChanged(sender, e);
+
+                       StepperRendererManager.UpdateButtons(this, _downButton, _upButton, e);
+               }
+
+               protected override void UpdateBackgroundColor()
+               {
+                       // don't call base
+               }
+
+               // IStepperRenderer
+
+               Stepper IStepperRenderer.Element => Element;
+
+               AButton IStepperRenderer.UpButton => _upButton;
+
+               AButton IStepperRenderer.DownButton => _downButton;
+
+               AButton IStepperRenderer.CreateButton()
+               {
+                       var button = new MButton(MaterialContextThemeWrapper.Create(Context), null, Resource.Attribute.materialOutlinedButtonStyle);
+
+                       // the buttons are meant to be "square", but are usually wide,
+                       // so, copy the vertical properties into the horizontal properties
+                       button.SetMinimumWidth(button.MinimumHeight);
+                       button.SetMinWidth(button.MinHeight);
+                       button.SetPadding(button.PaddingTop, button.PaddingTop, button.PaddingBottom, button.PaddingBottom);
+
+                       return button;
+               }
+       }
+}
+#endif
index 1604d0d..278ae3b 100644 (file)
@@ -1,14 +1,13 @@
 using System;
 using System.ComponentModel;
 using Android.Content;
-using Android.Views;
 using Android.Widget;
+using Android.Views;
 using AButton = Android.Widget.Button;
-using Object = Java.Lang.Object;
 
 namespace Xamarin.Forms.Platform.Android
 {
-       public class StepperRenderer : ViewRenderer<Stepper, LinearLayout>
+       public class StepperRenderer : ViewRenderer<Stepper, LinearLayout>, IStepperRenderer
        {
                AButton _downButton;
                AButton _upButton;
@@ -27,7 +26,12 @@ namespace Xamarin.Forms.Platform.Android
 
                protected override LinearLayout CreateNativeControl()
                {
-                       return new LinearLayout(Context) { Orientation = Orientation.Horizontal };
+                       return new LinearLayout(Context)
+                       {
+                               Orientation = Orientation.Horizontal,
+                               Focusable = true,
+                               DescendantFocusability = DescendantFocusability.AfterDescendants
+                       };
                }
 
                protected override void OnElementChanged(ElementChangedEventArgs<Stepper> e)
@@ -36,74 +40,33 @@ namespace Xamarin.Forms.Platform.Android
 
                        if (e.OldElement == null)
                        {
-                               _downButton = new AButton(Context) { Text = "-", Gravity = GravityFlags.Center, Tag = this };
-                               _downButton.SetHeight((int)Context.ToPixels(10.0));
-
-                               _downButton.SetOnClickListener(StepperListener.Instance);
-
-                               _upButton = new AButton(Context) { Text = "+", Tag = this };
-
-                               _upButton.SetOnClickListener(StepperListener.Instance);
-                               _upButton.SetHeight((int)Context.ToPixels(10.0));
-
                                var layout = CreateNativeControl();
-
-                               layout.AddView(_downButton);
-                               layout.AddView(_upButton);
-
+                               StepperRendererManager.CreateStepperButtons(this, out _downButton, out _upButton);
+                               layout.AddView(_downButton, new LinearLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.MatchParent));
+                               layout.AddView(_upButton, new LinearLayout.LayoutParams(LayoutParams.WrapContent, LayoutParams.MatchParent));
                                SetNativeControl(layout);
                        }
 
-                       UpdateButtonEnabled();
+                       StepperRendererManager.UpdateButtons(this, _downButton, _upButton);
                }
 
                protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
                {
                        base.OnElementPropertyChanged(sender, e);
-
-                       switch (e.PropertyName)
-                       {
-                               case "Minimum":
-                                       UpdateButtonEnabled();
-                                       break;
-                               case "Maximum":
-                                       UpdateButtonEnabled();
-                                       break;
-                               case "Value":
-                                       UpdateButtonEnabled();
-                                       break;
-                               case "IsEnabled":
-                                       UpdateButtonEnabled();
-                                       break;
-                       }
+                       StepperRendererManager.UpdateButtons(this, _downButton, _upButton, e);
                }
 
-               void UpdateButtonEnabled()
-               {
-                       Stepper view = Element;
-                       _upButton.Enabled = view.IsEnabled ? view.Value < view.Maximum : view.IsEnabled;
-                       _downButton.Enabled = view.IsEnabled ? view.Value > view.Minimum : view.IsEnabled;
-               }
+               Stepper IStepperRenderer.Element => Element;
 
-               class StepperListener : Object, IOnClickListener
-               {
-                       public static readonly StepperListener Instance = new StepperListener();
+               AButton IStepperRenderer.UpButton => _upButton;
 
-                       public void OnClick(global::Android.Views.View v)
-                       {
-                               var renderer = v.Tag as StepperRenderer;
-                               if (renderer == null)
-                                       return;
-
-                               Stepper stepper = renderer.Element;
-                               if (stepper == null)
-                                       return;
+               AButton IStepperRenderer.DownButton => _downButton;
 
-                               if (v == renderer._upButton)
-                                       ((IElementController)stepper).SetValueFromRenderer(Stepper.ValueProperty, stepper.Value + stepper.Increment);
-                               else if (v == renderer._downButton)
-                                       ((IElementController)stepper).SetValueFromRenderer(Stepper.ValueProperty, stepper.Value - stepper.Increment);
-                       }
+               AButton IStepperRenderer.CreateButton()
+               {
+                       var button = new AButton(Context);
+                       button.SetHeight((int)Context.ToPixels(10.0));
+                       return button;
                }
        }
-}
\ No newline at end of file
+}
index 17aab91..118b50f 100644 (file)
@@ -1,9 +1,12 @@
 <?xml version="1.0" encoding="UTF-8" ?>
 <resources>
 
+  <attr name="materialOutlinedButtonStyle" format="reference"/>
+
   <!-- Material Base Theme -->
   <style name="XamarinFormsMaterialTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">
     <item name="materialButtonStyle">@style/XamarinFormsMaterialButton</item>
+    <item name="materialOutlinedButtonStyle">@style/XamarinFormsMaterialButtonOutlined</item>
   </style>
 
   <!-- Material Sliders -->
@@ -18,7 +21,6 @@
   <style name="XamarinFormsMaterialProgressBarCircular" parent="Widget.AppCompat.ProgressBar">
     <item name="android:background">@drawable/MaterialActivityIndicatorBackground</item>
   </style>
-
   <!-- Material Buttons (All Styles) -->
   <style name="XamarinFormsMaterialButton" parent="Widget.MaterialComponents.Button">
     <item name="android:insetTop">0dp</item>
     <item name="android:paddingTop">8dp</item>
     <item name="android:paddingBottom">8dp</item>
   </style>
+  <style name="XamarinFormsMaterialButtonOutlined" parent="Widget.MaterialComponents.Button.OutlinedButton">
+    <item name="android:insetTop">0dp</item>
+    <item name="android:insetBottom">0dp</item>
+    <item name="android:minHeight">36dp</item>
+    <item name="android:paddingTop">8dp</item>
+    <item name="android:paddingBottom">8dp</item>
+  </style>
   <style name="XamarinFormsMaterialEntryFilled" parent="Widget.MaterialComponents.TextInputLayout.FilledBox">
     <item name="boxCollapsedPaddingTop">8dp</item>
   </style>
-
 </resources>
diff --git a/Xamarin.Forms.Platform.Android/StepperRendererManager.cs b/Xamarin.Forms.Platform.Android/StepperRendererManager.cs
new file mode 100644 (file)
index 0000000..0c4d45b
--- /dev/null
@@ -0,0 +1,79 @@
+using System.ComponentModel;
+using Android.Views;
+using AButton = Android.Widget.Button;
+using AView = Android.Views.View;
+
+namespace Xamarin.Forms.Platform.Android
+{
+       public static class StepperRendererManager
+       {
+               public static void CreateStepperButtons<TButton>(IStepperRenderer renderer, out TButton downButton, out TButton upButton)
+                       where TButton : AButton
+               {
+                       downButton = (TButton)renderer.CreateButton();
+                       downButton.Id = Platform.GenerateViewId();
+                       downButton.Focusable = true;
+                       upButton = (TButton)renderer.CreateButton();
+                       upButton.Id = Platform.GenerateViewId();
+                       upButton.Focusable = true;
+
+                       downButton.Gravity = GravityFlags.Center;
+                       downButton.Tag = renderer as Java.Lang.Object;
+                       downButton.SetOnClickListener(StepperListener.Instance);
+                       upButton.Gravity = GravityFlags.Center;
+                       upButton.Tag = renderer as Java.Lang.Object;
+                       upButton.SetOnClickListener(StepperListener.Instance);
+
+                       // IMPORTANT:
+                       // Do not be decieved. These are NOT the same characters. Neither are a "minus" either.
+                       // The Text is a visually pleasing "minus", and the description is the phonetically correct "minus".
+                       // The little key on your keyboard is a dash/hyphen.
+                       downButton.Text = "-";
+                       downButton.ContentDescription = "−";
+
+                       // IMPORTANT:
+                       // Do not be decieved. These are NOT the same characters.
+                       // The Text is a visually pleasing "plus", and the description is the phonetically correct "plus"
+                       // (which, unlike the minus, IS found on your keyboard).
+                       upButton.Text = "+";
+                       upButton.ContentDescription = "+";
+
+                       downButton.NextFocusForwardId = upButton.Id;
+               }
+
+               public static void UpdateButtons<TButton>(IStepperRenderer renderer, TButton downButton, TButton upButton, PropertyChangedEventArgs e = null)
+                       where TButton : AButton
+               {
+                       if (!(renderer?.Element is Stepper stepper))
+                               return;
+
+                       // NOTE: a value of `null` means that we are forcing an update
+                       if (e == null ||
+                               e.IsOneOf(Stepper.MinimumProperty, Stepper.MaximumProperty, Stepper.ValueProperty, VisualElement.IsEnabledProperty))
+                       {
+                               downButton.Enabled = stepper.IsEnabled && stepper.Value > stepper.Minimum;
+                               upButton.Enabled = stepper.IsEnabled && stepper.Value < stepper.Maximum;
+                       }
+               }
+
+               class StepperListener : Java.Lang.Object, AView.IOnClickListener
+               {
+                       public static readonly StepperListener Instance = new StepperListener();
+
+                       public void OnClick(AView v)
+                       {
+                               if (!(v?.Tag is IStepperRenderer renderer))
+                                       return;
+
+                               if (!(renderer?.Element is Stepper stepper))
+                                       return;
+
+                               var increment = stepper.Increment;
+                               if (v == renderer.DownButton)
+                                       increment = -increment;
+
+                               ((IElementController)stepper).SetValueFromRenderer(Stepper.ValueProperty, stepper.Value + increment);
+                       }
+               }
+       }
+}
index 3f20ab0..1c281aa 100644 (file)
     <Compile Include="ButtonLayoutManager.cs" />
     <Compile Include="Material\MaterialSliderRenderer.cs" />
     <AndroidResource Include="Resources\color\white_disabled_material.xml" />
+    <Compile Include="IStepperRenderer.cs" />
+    <Compile Include="StepperRendererManager.cs" />
+    <Compile Include="Material\MaterialStepperRenderer.cs" />
   </ItemGroup>
   <Import Project="$(MSBuildExtensionsPath)\Xamarin\Android\Xamarin.Android.CSharp.targets" />
   <!-- To modify your build process, add your task inside one of the targets below and uncomment it.