Xamarin.forms: [F100] Rounded Corners

Created on 31 Jan 2018  Â·  26Comments  Â·  Source: xamarin/Xamarin.Forms

Rationale

Rounded corners are a common visual requirement which Forms does not support without custom renderers. Forms should provide a common interface for defining rounded corners on Views.

Implementation

We need to add an interface for defining what's required for a View to implement corner rounding (and provide CSS support):

interface IRoundedCorners
{
    CornerRadius CornerRadius { get; }
    void CornerRadiusChanged(CornerRadius oldValue, CornerRadius newValue);
}

The CornerRadius BindableProperty can be implemented in one place:

static class RoundedCornerElement
{
    public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create(nameof(CornerRadius), typeof(CornerRadius), typeof(IRoundedCorners));
}

Renderer implementations should be careful to take Borders into consideration.

The following Views are candidates for rounded corners:
BoxView (in progress: https://github.com/xamarin/Xamarin.Forms/issues/1709)
ImageView
StackLayout
Grid
AbsoluteLayout
ScrollView
(probably others)

Difficulty: Easy

Providing the interface is simple; individual implementations may vary in difficulty.

F100 help wanted inactive high impact proposal-accepted roadmap enhancement âž• up-for-grabs

Most helpful comment

I would also suggest that the property CornerRadius be a Thickness, not a Single, permitting us to round each corner separately.

All 26 comments

One common requirement is to create an ellipse / circle (depending on geometry of the view). This can't be done easily with a numerical value for border rounding - it's better done as a percentage, or with a flag that says "create an ellipse". Please consider using "special" values for the flag (e.g. -2 for ellipse), or making the values work like Grid width/height do.

Perhaps this could eventually be extended to allow a BezierPath for the border of a view? All the platforms support doing this.

Maybe the right option is to have an interface called IMask or IClip, and allow specifying the method used for clipping/masking the View (e.g., CornerRadius, Path, Ellipse).

Interesting idea!

make sure the default value is -1 aka RoundedCornerElement.DefaultCornerRadius

I'm wondering if we couldn't put that property in BorderElement

https://github.com/NAXAM/effects-xamarin-forms
have a look at ViewEffects, Naxam has done a great job on this one, might help to speed things up :)

it covers borders, radius and shadows on Views

This would be a great enhancement, a helping hand for making layouts simpler and take less time to render.

Please see my comment here https://github.com/xamarin/Xamarin.Forms/pull/1998#issuecomment-369867412 Nothing new maybe, I think you are aware of it.

I think this property should be on the View class, and the implementation in first phase should be starting with ContentView, StackLayout, Grid, Entry. I think we have it already on Frame and Button?

One interesting this is once we have it on Image, we will be able to have a very simple way to implement round profile images :) There will be no need to add extra plugins.

It technically suffices, but it’s semantically wrong

On 2 Mar 2018, at 11:04, Andrei N notifications@github.com wrote:

@hartez https://github.com/hartez Is there a need to have a CornerRadius type? Why doesn't Thickness suffice?

—
You are receiving this because you were assigned.
Reply to this email directly, view it on GitHub https://github.com/xamarin/Xamarin.Forms/issues/1754#issuecomment-369878703, or mute the thread https://github.com/notifications/unsubscribe-auth/AATGqy7rgadWyDk7tpGObuEJkXObW67Yks5taRk6gaJpZM4RzMVt.

@StephaneDelcroix I figured that out, notice I already had deleted my comment. Thanks.
CornerRadius type makes sense.

I would also suggest that the property CornerRadius be a Thickness, not a Single, permitting us to round each corner separately.

This is a good idea! Could this also apply to non-view type elements in xaml?

Right now, Button does have border radius, but Entry does not, for example. I have a project right now where the design calls for a lot of rounded features, and this issue addresses some of those, but not Entry.

The best idea would be if other all stacklayout could by rounded for example custom maps etc.

image

Could you guys give us the option to chose witch corners should be rounded so I can do things like these:
screenshot-1537560408080

@ederbond I was looking into implement this (at least as an Effect for now) and having some weirdness on Android trying to get a ViewOutlineProvider to return an outline with different corner radii specified to actually render. Doing a simple same corner radius on all corners works absolutely fine, but it seems like an outline clipped with different corner radii is not supported. I see you posted a screenshot here presumably with this working. I'm curious what approach you took as the few I've tried haven't been successful so far (and it looks like Android limitations).

EDIT: Nevermind, I didn't realize the BoxView renderer already did this and is just using a GradientDrawable and setting it to the background... Took that approach and it's working well.

So just for reference, this is my code:

public class RoundedCornerView : StackLayout
    {
        public static readonly BindableProperty RoundedCornersProperty = BindableProperty.Create(nameof(RoundedCorners), typeof(string), typeof(RoundedCornerView), "All", validateValue: OnRoundedCornersPropertyValidateValue);
        public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create(nameof(CornerRadius), typeof(double), typeof(RoundedCornerView), Convert.ToDouble(11));
        public static readonly BindableProperty ShadowColorProperty = BindableProperty.Create(nameof(ShadowColor), typeof(Color), typeof(RoundedCornerView), Color.Black);
        public static readonly BindableProperty VerticalShadowOffsetProperty = BindableProperty.Create(nameof(VerticalShadowOffset), typeof(double), typeof(RoundedCornerView), -1d);
        public static readonly BindableProperty HorizontalShadowOffsetProperty = BindableProperty.Create(nameof(HorizontalShadowOffset), typeof(double), typeof(RoundedCornerView), 1d);
        public static readonly BindableProperty ShadowOpacityProperty = BindableProperty.Create(nameof(ShadowOpacity), typeof(float), typeof(RoundedCornerView), 0.8f);
        public static readonly BindableProperty ShadowRadiusProperty = BindableProperty.Create(nameof(ShadowRadius), typeof(float), typeof(RoundedCornerView), 2.0f);
        public static readonly BindableProperty BorderColorProperty = BindableProperty.Create(nameof(BorderColor), typeof(Color), typeof(RoundedCornerView), Color.Transparent);
        public static readonly BindableProperty BorderThicknessProperty = BindableProperty.Create(nameof(BorderThickness), typeof(float), typeof(RoundedCornerView), 2f);


        /// <summary>
        /// The default value is "AllCorners" witch makes all corners rounder.
        /// To round the corners individually, uses a combination of these values "TopLeft, TopRight, BottomLeft, BottomRight" separated by comma.
        /// </summary>
        public string RoundedCorners
        {
            get => (string)GetValue(RoundedCornersProperty);
            set => SetValue(RoundedCornersProperty, value);
        }

        public double CornerRadius
        {
            get => (double) GetValue(CornerRadiusProperty);
            set => SetValue(CornerRadiusProperty, value);
        }

        public Color BorderColor
        {
            get => (Color) GetValue(BorderColorProperty);
            set => SetValue(BorderColorProperty, value);
        }

        public float BorderThickness
        {
            get => (float) GetValue(BorderThicknessProperty);
            set => SetValue(BorderThicknessProperty, value);
        }

        public Color ShadowColor
        {
            get => (Color) GetValue(ShadowColorProperty);
            set => SetValue(ShadowColorProperty, value);
        }

        public double VerticalShadowOffset
        {
            get => (double) GetValue(VerticalShadowOffsetProperty);
            set => SetValue(VerticalShadowOffsetProperty, value);
        }

        public double HorizontalShadowOffset
        {
            get => (double) GetValue(HorizontalShadowOffsetProperty);
            set => SetValue(HorizontalShadowOffsetProperty, value);
        }

        public float ShadowOpacity
        {
            get => (float) GetValue(ShadowOpacityProperty);
            set => SetValue(ShadowOpacityProperty, value);
        }

        public float ShadowRadius
        {
            get => (float) GetValue(ShadowRadiusProperty);
            set => SetValue(ShadowRadiusProperty, value);
        }

        private static bool OnRoundedCornersPropertyValidateValue(BindableObject bindable, object value)
        {
            var allowedValues = new string[] { "topleft", "topright", "bottomleft", "bottomright", "all", "none" };

            return value.ToString().Split(',').Select(x => x.Trim().ToLower())
                                              .All(item => allowedValues.Contains(item));
        }
    }

The iOS Renderer

[assembly: ExportRenderer(typeof(RoundedCornerView), typeof(RoundedCornerViewRenderer))]
namespace MyProject.Mobile.iOS.Renderers
{
    public class RoundedCornerViewRenderer : ViewRenderer
    {
        private bool _isDisposed;

        protected override void OnElementChanged(ElementChangedEventArgs<View> e)
        {
            base.OnElementChanged(e);

            if (Element == null) return;

            Element.PropertyChanged += OnElementOnPropertyChanged;
        }

        private void OnElementOnPropertyChanged(object sender, PropertyChangedEventArgs e1)
        {
            if (_isDisposed || NativeView == null) return;

            NativeView.SetNeedsDisplay();
            NativeView.SetNeedsLayout();
        }

        public override void Draw(CGRect rect)
        {
            var view = (RoundedCornerView) Element;

            UIRectCorner corners = 0;

            if (view.RoundedCorners.ToLower().Contains("topleft"))
                corners = corners | UIRectCorner.TopLeft;

            if (view.RoundedCorners.ToLower().Contains("topright"))
                corners = corners | UIRectCorner.TopRight;

            if (view.RoundedCorners.ToLower().Contains("bottomright"))
                corners = corners | UIRectCorner.BottomRight;

            if (view.RoundedCorners.ToLower().Contains("bottomleft"))
                corners = corners | UIRectCorner.BottomLeft;

            if (view.RoundedCorners.ToLower().Contains("all"))
                corners = UIRectCorner.AllCorners;

            var mPath = UIBezierPath.FromRoundedRect(Layer.Bounds, corners, new CGSize(view.CornerRadius, view.CornerRadius)).CGPath;


            Layer.ShadowColor = view.ShadowColor.ToCGColor();
            Layer.ShadowOffset = new CGSize(view.HorizontalShadowOffset, view.VerticalShadowOffset);
            Layer.ShadowOpacity = view.ShadowOpacity;
            Layer.ShadowRadius = view.ShadowRadius;


            if (Layer.Sublayers == null || Layer.Sublayers.Length <= 0) return;

            var subLayer = this.Layer.Sublayers[0];
            subLayer.CornerRadius = (float) view.CornerRadius;
            subLayer.Mask = new CAShapeLayer
            {
                Frame = Layer.Bounds,
                Path = mPath,
            };
        }

        protected override void Dispose(bool disposing)
        {
            Element.PropertyChanged -= OnElementOnPropertyChanged;
            base.Dispose(disposing);
            _isDisposed = true;
        }
    }
}

The Android Renderer:

[assembly: ExportRenderer(typeof(RoundedCornerView), typeof(RoundedCornerViewRenderer))]
namespace AI.Mobile.Droid.Renderers
{
    public class RoundedCornerViewRenderer : ViewRenderer
    {
        public RoundedCornerViewRenderer(Context context) : base(context)
        { }

        protected override bool DrawChild(Canvas canvas, View child, long drawingTime)
        {
            if (Element == null) return false;

            var control = (RoundedCornerView) Element;

            //var drawable = GenerateBackgroundWithShadow(control, child, Color.White, Color.Black, 10, GravityFlags.Top);
            //return base.DrawChild(canvas, child, drawingTime);

            //child.Elevation = 15;

            SetClipChildren(true);

            control.Padding = new Thickness(0, 0, 0, 0);

            //Create path to clip the child         
            var path = new Path();
            path.AddRoundRect(new RectF(0, 0, Width, Height),
                              GetRadii(control),
                              Path.Direction.Ccw);

            canvas.Save();
            canvas.ClipPath(path);

            // Draw the child first so that the border shows up above it.        
            var result = base.DrawChild(canvas, child, drawingTime);

            canvas.Restore();

            DrawBorder(canvas, control, path);

            //Properly dispose        
            path.Dispose();
            return result;
        }

        public static Drawable GenerateBackgroundWithShadow(RoundedCornerView control, View child, Color backgroundColor,
                                                            Color shadowColor,
                                                            int elevation,
                                                            GravityFlags shadowGravity)
        {
            var radii = GetRadii(control);

            int DY;
            switch (shadowGravity)
            {
                case GravityFlags.Center:
                    DY = 0;
                    break;
                case GravityFlags.Top:
                    DY = -1 * elevation / 3;
                    break;
                default:
                case GravityFlags.Bottom:
                    DY = elevation / 3;
                    break;
            }

            var shapeDrawable = new ShapeDrawable();

            shapeDrawable.Paint.Color = backgroundColor;
            shapeDrawable.Paint.SetShadowLayer(elevation, 0, DY, shadowColor);

            child.SetLayerType(LayerType.Software, shapeDrawable.Paint);

            shapeDrawable.Shape = new RoundRectShape(radii, null, null);

            var drawable = new LayerDrawable(new Drawable[] { shapeDrawable });
            drawable.SetLayerInset(0, elevation, elevation, elevation, elevation);

            child.Background = drawable;
            return drawable;

        }

        private static float[] GetRadii(RoundedCornerView control)
        {
            var radius = (float) (control.CornerRadius);
            radius *= 2;

            var topLeft = control.RoundedCorners.ToLower().Contains("topleft") ? radius : 0;
            var topRight = control.RoundedCorners.ToLower().Contains("topright") ? radius : 0;
            var bottomLeft = control.RoundedCorners.ToLower().Contains("bottomleft") ? radius : 0;
            var bottomRight = control.RoundedCorners.ToLower().Contains("bottomright") ? radius : 0;

            if (control.RoundedCorners.ToLower().Contains("all"))
                topLeft = topRight = bottomLeft = bottomRight = radius;

            var radii = new[] { topLeft, topLeft, topRight, topRight, bottomRight, bottomRight, bottomLeft, bottomLeft };
            return radii;
        }

        private static void DrawBorder(Canvas canvas, RoundedCornerView control, Path path)
        {
            if (control.BorderColor == Xamarin.Forms.Color.Transparent ||
                control.BorderThickness <= 0) return;

            var paint = new Paint();
            paint.AntiAlias = true;
            paint.StrokeWidth = control.BorderThickness;
            paint.SetStyle(Paint.Style.Stroke);
            paint.Color = control.BorderColor.ToAndroid();

            canvas.DrawPath(path, paint);

            paint.Dispose();
        }
    }
}

Usage:

<controls:RoundedCornerView CornerRadius="80" RoundedCorners="TopLeft, TopRight"
                                  ShadowColor="Black" ShadowRadius="10" 
                                  HorizontalShadowOffset="5"
                                  VerticalShadowOffset="5"
                                  >
        <BoxView HeightRequest="100" WidthRequest="100" BackgroundColor="White"/>
</controls:RoundedCornerView>

The result I was looking for can be seen on the screenshot bellow where I have a bottom drawer with just TopRight and TopLeft corners rounded.

screenshot_20190111-171741

  • Be aware that Shadows properties is actually just working on iOS, on android I couldn't make them play right at the same time with the rounded corners. But you can set borders on Android if it can help...
  • If the content you are trying to put inside the RoundedCornerView has more than one child, you must put your content inside a single Layout control (StackLayout/Grid) or it will not render correctly on iOS.

There are some additional apis in support 28 that adds some material shapes which might also make this a little easier

https://github.com/material-components/material-components-android/issues/136

I'm currently playing around with them to see what they can all do but it's something to consider

iOS also has these but they require the Material components nuget
https://material.io/develop/ios/docs/supporting-shapes/

Thanks for sharing @cristiangiagante
The biggest chalenge is to have these individuals rounded corner with rounded shadows on Android
as described here

Worth mentioning Pancake View.

I've also got some code which does almost exactly the same thing as PancakeView but as an Effect instead, however it doesn't work on UWP properly (ultimately adding the effect to things like Grid or StackLayout ends up rendiner a UWP Panel which can't have CornerRadius set on it), and it has the same Android limitation where you can either have Shadows and Uniform corner radius, or no shadows and different corner radii.

This isn't the easiest one to actually implement properly without these limitations unfortunately.

@redth I’m currently in the process of implementing a way that separate rounded corner radii AND shadow work in PancakeView on Android. It involves creating your own path and feeding that to the ViewOutlineProvider. However that solution creates additional complexity with clipping which you would also need to do manually based on the same path. I’m convinced it’s possible but my efforts are currently halted for a few weeks of well deserved holiday 😅

@Redth I can confirm that this CAN be done using ViewOutlineProvider as long as the custom shape you're setting the shadow outline to is a convex shape. For a rounded rectangle, this should always be the case.

public override void GetOutline(global::Android.Views.View view, Outline outline)
{   
    var path = ShapeUtils.CreateRoundedRectPath(view.Width, view.Height,
        _convertToPixels(_pancake.CornerRadius.TopLeft),
        _convertToPixels(_pancake.CornerRadius.TopRight),
        _convertToPixels(_pancake.CornerRadius.BottomRight),
        _convertToPixels(_pancake.CornerRadius.BottomLeft));

    if (path.IsConvex)
    {
        outline.SetConvexPath(path);
    }
}

@sthewissen awesome! Would love to see what you come up with for getting android to play more nicely!

@Redth It's currently live in PancakeView 1.3.3. Seems to work like a charm.

Was this page helpful?
0 / 5 - 0 ratings