iosswiftswiftui

Drawing multiple gradients and inner shadow in SwiftUI


How can I create the metallic rim gradient as well as inner shadow as seen in the image below?

sample metallic dish

Here's my code:

import SwiftUI

struct GradientDishView: View {
    private let containerSize: CGFloat = 300

    var body: some View {
        VStack {
            Spacer()
            
            ZStack {
                
                Canvas { context, size in
                    
                    let currentCenter = CGPoint(x: containerSize / 2, y: containerSize / 2)
                    
                    let baseRadius = containerSize / 2
                    let innerRimRadius = baseRadius * 0.90
                    let fillRadius = innerRimRadius
                    let rimWidth = baseRadius - innerRimRadius
                    
                    // inner fill
                    let innerPath = Path(ellipseIn: CGRect(
                        x: currentCenter.x - fillRadius, y: currentCenter.y - fillRadius,
                        width: fillRadius * 2, height: fillRadius * 2
                    ))
                    context.fill(innerPath, with: .color(Color.green))
                    
                    // gradient rim
                    let rimGradient = Gradient(stops: [
                        Gradient.Stop(color: Color(white: 0.9), location: 0.0),
                        Gradient.Stop(color: Color(white: 0.7), location: 0.2),
                        Gradient.Stop(color: Color(white: 0.9), location: 0.35),
                        Gradient.Stop(color: Color(white: 0.7), location: 0.45),
                        Gradient.Stop(color: Color(white: 0.9), location: 0.6),
                        Gradient.Stop(color: Color(white: 0.7), location: 0.7),
                        Gradient.Stop(color: Color(white: 0.9), location: 0.85),
                        Gradient.Stop(color: Color(white: 0.7), location: 0.95),
                        Gradient.Stop(color: Color(white: 0.9), location: 1.0)
                    ])
                    let rimCenterRadius = (baseRadius + innerRimRadius) / 2
                    let rimStrokePath = Path(ellipseIn: CGRect(
                        x: currentCenter.x - rimCenterRadius,
                        y: currentCenter.y - rimCenterRadius,
                        width: rimCenterRadius * 2,
                        height: rimCenterRadius * 2
                    ))
                    let gradientStart = CGPoint(x: currentCenter.x - baseRadius,
                                                y: currentCenter.y - baseRadius)
                    let gradientEnd = CGPoint(x: currentCenter.x + baseRadius,
                                              y: currentCenter.y + baseRadius)
                    context.stroke(rimStrokePath,
                                   with: .linearGradient(rimGradient, startPoint: gradientStart, endPoint: gradientEnd), lineWidth: rimWidth)
                }
                .frame(width: containerSize, height: containerSize)
                .shadow(color: .black.opacity(0.5), radius: 20, x: 0, y: 0)
            }
            
            Spacer()
        }
    }
}

#Preview {
    GradientDishView()
}

I'm using a Canvas as I will have other elements drawn over it later.

Edit: I updated the rimGradient colors and now I can somewhat get the darker gradients at 2 & 7 o'clock positions. How can I get similar lighter gradients at the opposite positions? I'd also like to get the inner shadow showing.

Edit 2: Updated rimGradient to use gradient stops and while it's not exactly the same as the image, its a little closer.


Solution

  • You could try using an AngularGradient for the rim.

    As Sweeper pointed out in a comment, the ring also has edges. One way to achieve a similar effect is to add very narrow inner and outer rings as overlays. Then a small blur can be applied to the result.

    Putting this together as a view which draws only the ring:

    struct SteelRing: View {
        let ringWidth: CGFloat
    
        private func gradientRing(colors: [Color], width: CGFloat) -> some View {
            AngularGradient(
                colors: colors,
                center: .center
            )
            .mask {
                Circle()
                    .strokeBorder(lineWidth: width)
            }
        }
    
        var body: some View {
            ZStack {
                gradientRing(
                    colors: [
                        Color(white: 0.97),
                        Color(white: 0.6),
    
                        Color(white: 0.92),
                        Color(white: 0.5),
    
                        Color(white: 0.97),
                        Color(white: 0.6),
    
                        Color(white: 0.92),
                        Color(white: 0.5),
    
                        Color(white: 0.97)
                    ],
                    width: ringWidth
                )
    
                gradientRing(
                    colors: [
                        Color(white: 0.7),
                        Color(white: 0.4),
                        Color(white: 0.7),
                        Color(white: 1),
                        Color(white: 0.7),
                    ],
                    width: 1
                )
    
                gradientRing(
                    colors: [
                        Color(white: 0.7),
                        Color(white: 1),
                        Color(white: 0.7),
                        Color(white: 0.4),
                        Color(white: 0.7),
                    ],
                    width: 1
                )
                .padding(ringWidth - 1)
            }
            .blur(radius: 0.5)
            .mask {
                Circle()
                    .strokeBorder(lineWidth: ringWidth)
            }
        }
    }
    

    Using this to reproduce the pie chart example:

    private let containerSize: CGFloat = 300
    private let rimWidth: CGFloat = 10
    
    var body: some View {
        ZStack {
            SteelRing(ringWidth: rimWidth)
    
            Circle()
                .fill(.shadow(.inner(color: Color(white: 0.6), radius: 8)))
                .foregroundStyle(
                    AngularGradient(
                        stops: [
                            .init(color: .orange, location: 0),
                            .init(color: .orange, location: 0.4),
                            .init(color: .green, location: 0.4),
                            .init(color: .green, location: 1)
                        ],
                        center: .center)
                )
                .rotationEffect(.degrees(-90))
                .padding(rimWidth)
        }
        .frame(width: containerSize, height: containerSize)
        .compositingGroup()
        .shadow(color: Color(white: 0.6), radius: 10)
    }
    

    Screenshot


    If the content will be something more elaborate then you will probably want the inner shadow to be a layer above the content, which means the white parts need to be transparent.

    The modifier .luminanceToAlpha can be used to convert black to transparent. So if the shadow is drawn using white shadow color over black fill, the black can be converted to transparent by applying this modifier:

    ZStack {
        SteelRing(ringWidth: rimWidth)
    
        Image(.image3)
            .resizable()
            .scaledToFill()
            .clipShape(Circle())
            .padding(rimWidth)
    
        Circle()
            .fill(.shadow(.inner(color: Color(white: 0.4), radius: 8)))
            .foregroundStyle(.black)
            .luminanceToAlpha()
            .padding(rimWidth)
    }
    .frame(width: containerSize, height: containerSize)
    .compositingGroup()
    .shadow(color: Color(white: 0.6), radius: 10)
    

    Screenshot