Skip to content
This repository has been archived by the owner on May 1, 2024. It is now read-only.

[F100] Rounded Corners #1754

Closed
hartez opened this issue Jan 30, 2018 · 28 comments
Closed

[F100] Rounded Corners #1754

hartez opened this issue Jan 30, 2018 · 28 comments
Labels
F100 help wanted We welcome community contributions to any issue, but these might be a good place to start! inactive Issue is older than 6 months and needs to be retested m/high impact ⬛ proposal-accepted roadmap t/enhancement ➕ up-for-grabs We welcome community contributions to any issue, but these might be a good place to start!

Comments

@hartez
Copy link
Contributor

hartez commented Jan 30, 2018

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: #1709)
ImageView
StackLayout
Grid
AbsoluteLayout
ScrollView
(probably others)

Difficulty: Easy

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

@programmation
Copy link

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.

@programmation
Copy link

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

@hartez
Copy link
Contributor Author

hartez commented Jan 31, 2018

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).

@programmation
Copy link

Interesting idea!

@StephaneDelcroix
Copy link
Member

StephaneDelcroix commented Jan 31, 2018

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

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

@bentmar
Copy link
Contributor

bentmar commented Feb 1, 2018

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

@andreinitescu
Copy link
Contributor

andreinitescu commented Mar 2, 2018

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

Please see my comment here #1998 (comment) 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.

@StephaneDelcroix
Copy link
Member

StephaneDelcroix commented Mar 2, 2018 via email

@andreinitescu
Copy link
Contributor

andreinitescu commented Mar 2, 2018

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

@StephaneDelcroix StephaneDelcroix removed their assignment Mar 8, 2018
@KTM450SXF
Copy link

KTM450SXF commented Mar 27, 2018

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

@melwil
Copy link

melwil commented Mar 28, 2018

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.

@HelloMyDevWorld
Copy link

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

image

@samhouts samhouts added the help wanted We welcome community contributions to any issue, but these might be a good place to start! label Jun 14, 2018
@ederbond
Copy link
Contributor

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

@samhouts samhouts added the up-for-grabs We welcome community contributions to any issue, but these might be a good place to start! label Oct 5, 2018
@Redth
Copy link
Member

Redth commented Jan 11, 2019

@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.

@ederbond
Copy link
Contributor

ederbond commented Jan 12, 2019

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>

@ederbond
Copy link
Contributor

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

@samhouts samhouts added this to the 4.4.0 milestone Aug 29, 2019
@Redth
Copy link
Member

Redth commented Sep 25, 2019

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.

@sthewissen
Copy link
Contributor

sthewissen commented Sep 26, 2019

@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 😅

@sthewissen
Copy link
Contributor

@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);
    }
}

@Redth
Copy link
Member

Redth commented Oct 8, 2019

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

@sthewissen
Copy link
Contributor

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

@samhouts samhouts modified the milestones: 4.4.0, 4.5.0 Nov 20, 2019
@samhouts samhouts added m/high impact ⬛ proposal-open and removed help wanted We welcome community contributions to any issue, but these might be a good place to start! up-for-grabs We welcome community contributions to any issue, but these might be a good place to start! inactive Issue is older than 6 months and needs to be retested labels Feb 7, 2020
@samhouts samhouts removed this from the 4.5.0 milestone Feb 11, 2020
@samhouts samhouts added inactive Issue is older than 6 months and needs to be retested help wanted We welcome community contributions to any issue, but these might be a good place to start! up-for-grabs We welcome community contributions to any issue, but these might be a good place to start! labels Apr 30, 2020
@ederbond
Copy link
Contributor

ederbond commented May 4, 2021

@Redth @jsuarezruiz @StephaneDelcroix @hartez @davidortinau please consider bringing part of the code from PacakeView to every Layout view of #dotnetmaui so we developers can set BorderColor, BorderWidth, CornerRadius, and Shadow to any visible control.

@jfversluis
Copy link
Member

This will not happen anymore for Xamarin.Forms. I think there is much to do about borders and corners and such in .NET MAUI, so there is a good chance that will all be better there.

If this is still important to you, make sure to check the .NET MAUI repo and see if it's already on the roadmap or open an issue with a detailed feature request. Thanks!

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
F100 help wanted We welcome community contributions to any issue, but these might be a good place to start! inactive Issue is older than 6 months and needs to be retested m/high impact ⬛ proposal-accepted roadmap t/enhancement ➕ up-for-grabs We welcome community contributions to any issue, but these might be a good place to start!
Projects
None yet
Development

No branches or pull requests