skip to Main Content

I created a custom shape in the form of a ring segment. I eventually want to use this shape as a button in my app, and therefore I need to be able to position text right in the middle of it. Below picture shows the ring segment (blue) and the text (red) I wish to center vertically inside the blue segment.

enter image description here

The code below creates the custom blue shape segment:

struct CurvedButtonData {
    enum ButtonLocation {
        case top
        case right
        case bottom
        case left
    }

    /// The  button's width as an angle in degrees.
    let width: Double = 90.0

    /// The button's start location, as an angle in degrees.
    fileprivate(set) var start = 0.0
    /// The button's end location, as an angle in degrees.
    fileprivate(set) var end = 0.0

    init(position: ButtonLocation) {
        switch position {
        case .top:
            start = -135
        case .right:
            start = -45
        case .bottom:
            start = 45
        case .left:
            start = 135
        }

        end = start + width
    }
}

struct CurvedButton: Shape, InsettableShape {
    let data: CurvedButtonData
    var insetAmount = 0.0

    func path(in rect: CGRect) -> Path {
        let points = CurvedButtonGeometry(curvedButtonData: data, rect: rect)

        var path = Path()

        path.addArc(center: points.center, radius: points.innerRadius, startAngle: .degrees(data.start), endAngle: .degrees(data.end), clockwise: false)

        path.addArc(center: points.center, radius: points.outerRadius - insetAmount, startAngle: .degrees(data.end), endAngle: .degrees(data.start), clockwise: true)

        path.closeSubpath()

        return path
    }

    func inset(by amount: CGFloat) -> CurvedButton {
        var button = self
        button.insetAmount += amount
        return button
    }
}

struct CurvedButtonGeometry {
    let data: CurvedButtonData
    let center: CGPoint
    let innerRadius: CGFloat
    let outerRadius: CGFloat

    init(curvedButtonData: CurvedButtonData, rect: CGRect) {
        center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 4
        innerRadius = radius
        outerRadius = radius * 2
        data = curvedButtonData
    }

    /// Returns the view location of the point in the wedge at unit-
    /// space location `unitPoint`, where the X axis of `p` moves around the
    /// wedge arc and the Y axis moves out from the inner to outer
    /// radius.
    subscript(unitPoint: UnitPoint) -> CGPoint {
        let radius = lerp(innerRadius, outerRadius, by: unitPoint.y)
        let angle = lerp(data.start, data.end, by: Double(unitPoint.x))

        return CGPoint(x: center.x + CGFloat(cos(angle)) * radius,
                       y: center.y + CGFloat(sin(angle)) * radius)
    }

    /// Linearly interpolate from `from` to `to` by the fraction `amount`.
    private func lerp<T: BinaryFloatingPoint>(_ fromValue: T, _ toValue: T, by amount: T) -> T {
        return fromValue + (toValue - fromValue) * amount
    }
}

And this is my pityful attempt to create a button with my custom shape and center the "Click me" text inside the blue area:

import SwiftUI

struct CircularCVBControl: View {
    var body: some View {
        Button {

        } label: {
            ZStack(alignment: .top) {
                CurvedButton(data: .init(position: .top))
                    .fill(.blue)
                    .overlay(alignment: .top) {
                        Text("Click me")
                            .foregroundColor(.red)
                    }
            }
        }
        .buttonStyle(.plain)
    }
}

struct CircularCVBControl_Previews: PreviewProvider {
    static var previews: some View {
        CircularCVBControl()
    }
}

How can I now neatly (and without using a hacky padding on the Text) center the text vertically in my custom shape? I am looking for some sort of custom alignment guide/logic that does the job. Also, the shape can be rotated in other directions too, and the text should rotate with it.

2

Answers


  1. The problem is that your curve does not render in the middle of the frame. To solve this, I believe you need to offset your center.
    Like this:

    center = CGPoint(x: rect.midX, y: rect.midY + innerRadius + (outerRadius - innerRadius)/2)
    

    This is a solution only for the ‘.top’ position. I will leave the other positions to you 😉

    Here is the full code:

    import SwiftUI
    
    struct CurvedButtonData {
        enum ButtonLocation {
            case top
            case right
            case bottom
            case left
        }
    
        /// The  button's width as an angle in degrees.
        let width: Double = 90.0
    
        /// The button's start location, as an angle in degrees.
        fileprivate(set) var start = 0.0
        /// The button's end location, as an angle in degrees.
        fileprivate(set) var end = 0.0
    
        init(position: ButtonLocation) {
            switch position {
            case .top:
                start = -135
            case .right:
                start = -45
            case .bottom:
                start = 45
            case .left:
                start = 135
            }
    
            end = start + width
        }
    }
    
    struct CurvedButton: Shape, InsettableShape {
        let data: CurvedButtonData
        var insetAmount = 0.0
    
        func path(in rect: CGRect) -> Path {
            let points = CurvedButtonGeometry(curvedButtonData: data, rect: rect)
    
            var path = Path()
    
            path.addArc(center: points.center, radius: points.innerRadius, startAngle: .degrees(data.start), endAngle: .degrees(data.end), clockwise: false)
    
            path.addArc(center: points.center, radius: points.outerRadius - insetAmount, startAngle: .degrees(data.end), endAngle: .degrees(data.start), clockwise: true)
    
            path.closeSubpath()
    
            return path
        }
    
        func inset(by amount: CGFloat) -> CurvedButton {
            var button = self
            button.insetAmount += amount
            return button
        }
    }
    
    struct CurvedButtonGeometry {
        let data: CurvedButtonData
        let center: CGPoint
        let innerRadius: CGFloat
        let outerRadius: CGFloat
    
        init(curvedButtonData: CurvedButtonData, rect: CGRect) {
            let radius = min(rect.width, rect.height) / 4
            innerRadius = radius
            outerRadius = radius * 2
            center = CGPoint(x: rect.midX, y: rect.midY + innerRadius + (outerRadius - innerRadius)/2)
            data = curvedButtonData
        }
    
        /// Returns the view location of the point in the wedge at unit-
        /// space location `unitPoint`, where the X axis of `p` moves around the
        /// wedge arc and the Y axis moves out from the inner to outer
        /// radius.
        subscript(unitPoint: UnitPoint) -> CGPoint {
            let radius = lerp(innerRadius, outerRadius, by: unitPoint.y)
            let angle = lerp(data.start, data.end, by: Double(unitPoint.x))
    
            return CGPoint(x: center.x + CGFloat(cos(angle)) * radius,
                           y: center.y + CGFloat(sin(angle)) * radius)
        }
    
        /// Linearly interpolate from `from` to `to` by the fraction `amount`.
        private func lerp<T: BinaryFloatingPoint>(_ fromValue: T, _ toValue: T, by amount: T) -> T {
            return fromValue + (toValue - fromValue) * amount
        }
    }
    
    
    
    struct CircularCVBControl: View {
        var body: some View {
            Button {
    
            } label: {
                ZStack() {
                    CurvedButton(data: .init(position: .top))
                        .fill(.blue)
                        Text("Click me")
                            .foregroundColor(.red)
                }
            }
            .buttonStyle(.plain)
        }
    }
    
    struct CircularCVBControl_Previews: PreviewProvider {
        static var previews: some View {
            CircularCVBControl()
        }
    }
    
    Login or Signup to reply.
  2. I see that you want to combine multiple Curvedbuttons together as one ‘circle’. In that case you indeed have to offset the Text instead of the Shape.
    You can do it with GeometryReader. I’ve implemented it for the .top position as shown below. You can add the other positions into the getOffset() method. I am not entirely happy with this code as some of the coordinates are not computed in more than one place. You can/should clean this code a lot but I wanted to demonstrate the core principle without changing everything. Hope this helps.

    import SwiftUI
    
    struct CurvedButtonData {
        enum ButtonLocation {
            case top
            case right
            case bottom
            case left
        }
    
        /// The  button's width as an angle in degrees.
        let width: Double = 90.0
    
        /// The button's start location, as an angle in degrees.
        fileprivate(set) var start = 0.0
        /// The button's end location, as an angle in degrees.
        fileprivate(set) var end = 0.0
    
        init(position: ButtonLocation) {
            switch position {
            case .top:
                start = -135
            case .right:
                start = -45
            case .bottom:
                start = 45
            case .left:
                start = 135
            }
    
            end = start + width
        }
    }
    
    struct CurvedButton: Shape, InsettableShape {
        let data: CurvedButtonData
        var insetAmount = 0.0
    
        func path(in rect: CGRect) -> Path {
            let points = CurvedButtonGeometry(curvedButtonData: data, rect: rect)
    
            var path = Path()
    
            path.addArc(center: points.center, radius: points.innerRadius, startAngle: .degrees(data.start), endAngle: .degrees(data.end), clockwise: false)
    
            path.addArc(center: points.center, radius: points.outerRadius - insetAmount, startAngle: .degrees(data.end), endAngle: .degrees(data.start), clockwise: true)
    
            path.closeSubpath()
    
            return path
        }
    
        func inset(by amount: CGFloat) -> CurvedButton {
            var button = self
            button.insetAmount += amount
            return button
        }
    }
    
    struct CurvedButtonGeometry {
        let data: CurvedButtonData
        let center: CGPoint
        let innerRadius: CGFloat
        let outerRadius: CGFloat
    
        init(curvedButtonData: CurvedButtonData, rect: CGRect) {
            let radius = min(rect.width, rect.height) / 4
            innerRadius = radius
            outerRadius = radius * 2
            center = CGPoint(x: rect.midX, y: rect.midY)
            data = curvedButtonData
        }
    
        /// Returns the view location of the point in the wedge at unit-
        /// space location `unitPoint`, where the X axis of `p` moves around the
        /// wedge arc and the Y axis moves out from the inner to outer
        /// radius.
        subscript(unitPoint: UnitPoint) -> CGPoint {
            let radius = lerp(innerRadius, outerRadius, by: unitPoint.y)
            let angle = lerp(data.start, data.end, by: Double(unitPoint.x))
    
            return CGPoint(x: center.x + CGFloat(cos(angle)) * radius,
                           y: center.y + CGFloat(sin(angle)) * radius)
        }
    
        /// Linearly interpolate from `from` to `to` by the fraction `amount`.
        private func lerp<T: BinaryFloatingPoint>(_ fromValue: T, _ toValue: T, by amount: T) -> T {
            return fromValue + (toValue - fromValue) * amount
        }
    }
    
    
    
    struct CircularCVBControl: View {
        var body: some View {
            Button {
    
            } label: {
                GeometryReader { geo in
                    CurvedButton(data: .init(position: .top))
                        .foregroundColor(.blue)
                        .overlay(
                            Text("Click me")
                                .foregroundColor(.red)
                                .offset(y: getOffset(geo))
                        )
                }
            }
            .buttonStyle(.plain)
        }
        
        func getOffset(_ geo : GeometryProxy) -> CGFloat {
            let radius = min(geo.size.width, geo.size.height) / 4
            let innerRadius = radius
            let outerRadius = radius * 2
            let offset = -1 * innerRadius - (outerRadius - innerRadius)/2
            return offset
        }
    }
    
    struct CircularCVBControl_Previews: PreviewProvider {
        static var previews: some View {
            CircularCVBControl()
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search