0

I made a button to match the style of the rest of my application, however it's too bulky. I tried a few things, but this is as small as I've been able to get it.

class ButtonPath {
    private let wiggle: Int = 3
    func points(height: Int) ->
        ((Int,Int),(Int,Int),(Int,Int),
         (Int,Int),(Int,Int),(Int,Int),
         (Int,Int),(Int,Int),(Int,Int),
         (Int,Int),(Int,Int),(Int,Int))
    {
        func rand() -> Int { Int.random(in: -wiggle...wiggle) }
        func r2(x: Int) -> Int { Int.random(in: -x...x) }
        let screen: CGRect = UIScreen.main.bounds
        let widthMx = CGFloat(0.9)
        let origin = (x:15, y:15)
        let width = Int(screen.width * widthMx)

        // Corner points
        let tl   = (x: origin.x + rand(),   y: origin.x + rand()) // tl = Top Left, etc.
        let tr   = (x: origin.x + width + rand(),      y: origin.y + rand())
        let bl   = (x: origin.x + rand(),   y: origin.y + height + rand())
        let br   = (x: origin.x + width + rand(),      y: origin.y + height + rand())

        // Arc controls, we're drawing a rectangle counter-clockwise from the top left
        let a1c1 = (x: origin.x + rand(),   y: Int(Double(origin.y+height+rand()) * 0.3)) // a1c1 = Arc 1 Control 1
        let a1c2 = (x: origin.x + rand(),   y: Int(Double(origin.y+height+rand()) * 0.6))
        let a2c1 = (x: Int(Double(origin.x+width+rand()) * 0.3), y: origin.y + height + rand())
        let a2c2 = (x: Int(Double(origin.x+width+rand()) * 0.6), y: origin.y + height + rand())
        let a3c1 = (x: origin.x + width + rand(),                y: Int(Double(origin.y + height+rand()) * 0.6))
        let a3c2 = (x: origin.x + width + rand(),                y: Int(Double(origin.y + height+rand()) * 0.3))
        let a4c1 = (x: Int(Double(origin.x+width+rand()) * 0.6), y: origin.y + rand())
        let a4c2 = (x: Int(Double(origin.x+width+rand()) * 0.6), y: origin.y + rand())

        return (
            t1: tl, tr: tr, b1: bl, br: br,
            a1c1: a1c1, a1c2: a1c2, a2c1: a2c1,
            a2c2:a2c2, a3c1:a3c1, a3c2:a3c2, a4c1:a4c1, a4c2:a4c2
        )
    }
    func path (height:Int) -> Path {
        let (tl, tr, bl, br, a1c1, a1c2, a2c1, a2c2, a3c1, a3c2, a4c1, a4c2) = points(height: height)
        return Path { path in
            path.move( to: CGPoint(x: tl.0, y: tl.1) )
            path.addCurve( to: CGPoint(x: bl.0, y: bl.1), control1: CGPoint(x: a1c1.0, y: a1c1.1), control2: CGPoint(x: a1c2.0, y: a1c2.1))
            path.addCurve( to: CGPoint(x: br.0, y: br.1), control1: CGPoint(x: a2c1.0, y: a2c1.1), control2: CGPoint(x: a2c2.0, y: a2c2.1))
            path.addCurve( to: CGPoint(x: tr.0, y: tr.1), control1: CGPoint(x: a3c1.0, y: a3c1.1), control2: CGPoint(x: a3c2.0, y: a3c2.1))
            path.addCurve( to: CGPoint(x: tl.0-2, y: tl.1), control1: CGPoint(x: a4c1.0, y: a4c1.1), control2: CGPoint(x: a4c2.0, y: a4c2.1))
        }
    }
}

Is there a function I could write to output the value of the corners, arcs, paths, etc?

the Tin Man
  • 158,662
  • 42
  • 215
  • 303
Bryan Potts
  • 901
  • 1
  • 7
  • 20
  • That is pretty neat code. Sometimes it's best not to shorten it as _much_ as possible, because then it goes back to unreadable. – George Feb 05 '20 at 22:50
  • "Bulky" is fine, as long as its readable, and maintainable. If you think that you can look back on this in a year and understand what's going on, then that's a good outcome. Personally, I recommend people make shapes using structs, whose functions computed properties give you points of the shape. See this example: https://gist.github.com/amomchilov/1ac8e63001bd703cd4e460ba57b4df2f – Alexander Feb 05 '20 at 23:39

1 Answers1

4

You can simplify your code in several ways:

  • Just use CGFloat everywhere instead of constantly converting between Int and Double.
  • Add some fileprivate operators for doing math on CGPoint. Or add them project-wide if you need them in other files.
  • Factor out a method that wiggles a point.
  • Factor out a method that draws a wiggly line from the current point to another point.

You're apparently using the Path type. So I deduce you are using SwiftUI.

SwiftUI has a Shape protocol that you might want to use. You conform to the Shape protocol by defining a method path(in frame: CGRect) -> Path. Since SwiftUI gives you the frame of your Shape, you don't have to use a GeometryReader to get your shape's size.

Also, since SwiftUI can ask a View (including a Shape or a Path) for its body at any time, and as many times as it wants, it's important to make the code deterministic. That means not using the SystemRandomNumberGenerator. So let's start by defining a deterministic RandomNumberGenerator:

struct Xorshift128Plus: RandomNumberGenerator {
    var seed: (UInt64, UInt64)

    mutating func next() -> UInt64 {
        // Based on https://stackoverflow.com/a/40385556/77567
        // The state must be seeded so that it is not everywhere zero.
        if seed == (0, 0) { seed = (0, 1) }
        var x = seed.0
        x ^= x << 23
        x ^= x >> 17
        x ^= seed.1
        x ^= seed.1 >> 26
        seed = (seed.1, x)
        return seed.0 &+ seed.1
    }
}

We'll also want these arithmetic operators on CGPoint:

fileprivate func *(s: CGFloat, p: CGPoint) -> CGPoint { .init(x: s * p.x, y: s * p.y) }
fileprivate func +(p: CGPoint, q: CGPoint) -> CGPoint { .init(x: p.x + q.x, y: p.y + q.y) }
fileprivate func -(p: CGPoint, q: CGPoint) -> CGPoint { .init(x: p.x - q.x, y: p.y - q.y) }

Now we're ready to implement a method that wiggles a point:

extension CGPoint {
    fileprivate func wiggled<RNG: RandomNumberGenerator>(by wiggle: CGFloat, using rng: inout RNG) -> CGPoint {
        return .init(
            x: x + CGFloat.random(in: -wiggle...wiggle, using: &rng),
            y: y + CGFloat.random(in: -wiggle...wiggle, using: &rng))
    }
}

Note that we are generic over the RandomNumberGenerator type because we take it inout. We could hardcode Xorshift128Plus or any other concrete implementation of RandomNumberGenerator instead of being generic. But why limit ourselves?

With the point wiggler in hand, we can implement an addWigglyLine method on Path:

extension Path {
    fileprivate mutating func addWigglyLine<RNG: RandomNumberGenerator>(
        to p3: CGPoint, wiggle: CGFloat, rng: inout RNG)
    {
        let p0 = currentPoint ?? .zero
        let v = p3 - p0
        let p1 = (p0 + (1/3) * v).wiggled(by: wiggle, using: &rng)
        let p2 = (p0 + (2/3) * v).wiggled(by: wiggle, using: &rng)
        addCurve(to: p3, control1: p1, control2: p2)
    }
}

And finally we can implement WigglyRect, a custom Shape. Again we need to be generic over the RandomNumberGenerator. Note that since we need to mention the start/end point twice, we store it wiggled in p0 so that we can close the path neatly.

struct WigglyRect<RNG: RandomNumberGenerator>: Shape {
    var rng: RNG
    var wiggle: CGFloat = 8

    func path(in frame: CGRect) -> Path {
        var rng = self.rng
        func p(_ x: CGFloat, _ y: CGFloat) -> CGPoint {
            return CGPoint(x: x, y: y).wiggled(by: wiggle, using: &rng)
        }

        let rect = frame.insetBy(dx: wiggle, dy: wiggle)
        let (x0, x1, y0, y1) = (rect.minX, rect.maxX, rect.minY, rect.maxY)
        let p0 = p(x0, y0)
        var path = Path()
        path.move(to: p0)
        path.addWigglyLine(to: p(x1, y0), wiggle: wiggle, rng: &rng)
        path.addWigglyLine(to: p(x1, y1), wiggle: wiggle, rng: &rng)
        path.addWigglyLine(to: p(x0, y1), wiggle: wiggle, rng: &rng)
        path.addWigglyLine(to: p0, wiggle: wiggle, rng: &rng)
        path.closeSubpath()
        return path
    }
}

I tested on macOS. To use your code, I changed your use of UIScreen.main.bounds to CGRect(x: 0, y: 0, width: 300, height: 600). Here's the preview code:

struct ContentView: View {
    @State var seed: (UInt64, UInt64) = makeSeed()

    var body: some View {
        VStack(spacing: 20) {
            ButtonPath().path(height: 100)
                .stroke()
                .frame(height: 100)
                .background(Color.pink.opacity(0.2))
            WigglyRect(rng: Xorshift128Plus(seed: seed))
                .stroke()
                .frame(height: 100)
                .background(Color.pink.opacity(0.2))
            Spacer()
            Button("Reseed") {
                self.seed = Self.makeSeed()
            }
        } //
            .padding()
    }

    private static func makeSeed() -> (UInt64, UInt64) {
        (UInt64.random(in: .min ... .max), UInt64.random(in: .min ... .max))
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .previewLayout(PreviewLayout.fixed(width: 300, height: 600))
    }
}

Here's what it looks like:

demo of wiggly buttons

Your code draws the top shape. The code in this answer draws the bottom shape. I put pink backgrounds behind each path, showing the path's frame. Note that your code doesn't make the path conform to its bounds.

rob mayoff
  • 375,296
  • 67
  • 796
  • 848
  • Thanks for taking the time to write such a thoughtful answer, Rob. This is exactly what I was hoping for and more. Incredible! Thanks. – Bryan Potts Feb 08 '20 at 14:28