skip to Main Content

Is there any way to create something like this with SwiftUI (w/o using D3.js) –

// test data
    @State private var data: [DataItem] = [
        DataItem(title: "chrome", weight: 180, color: .green),
        DataItem(title: "firefox", weight: 60, color: .red),
        DataItem(title: "safari", weight: 90, color: .blue),
        DataItem(title: "edge", weight: 30, color: .orange),
        DataItem(title: "ie", weight: 50, color: .yellow),
        DataItem(title: "opera", weight: 25, color: .purple)
    ]

Here in the test data, "weight" denotes which item should be larger/smaller.

enter image description here

One way I can think of is to have X Circles in a given view with size relative to the parent. But that in itself creates issues for positioning and making sure that circles are not touching or overlapping each other.

Not sure about the usage of SpriteKit here? Can that be used or can this be achieved using SwiftUI components only?

2

Answers


  1. This was fun, going back to school math 🙂
    Here is my result. The smaller circles all align around the first large one, I didn’t solve the positioning of "opera" in your image, it seems to be accidentally.

    enter image description here

    Here is the code:
    It’s a starting point, no safety checks

    import SwiftUI
    
    struct DataItem: Identifiable {
        var id = UUID()
        var title: String
        var size: CGFloat
        var color: Color
        var offset = CGSize.zero
    }
    
    
    struct ContentView: View {
        
        // test data
        @State private var data: [DataItem] = [
            DataItem(title: "chrome", size: 180, color: .green),
            DataItem(title: "firefox", size: 60, color: .red),
            DataItem(title: "safari", size: 90, color: .blue),
            DataItem(title: "edge", size: 30, color: .orange),
            DataItem(title: "ie", size: 50, color: .yellow),
            DataItem(title: "opera", size: 25, color: .mint)
        ]
        
                
        var body: some View {
            
            ZStack {
                ForEach(data, id: .id) { item in
                    ZStack {
                        Circle()
                            .frame(width: CGFloat(item.size))
                            .foregroundColor(item.color)
                        Text(item.title)
                    }
                    .offset(item.offset)
                }
            }
            
            // calculate and set the offsets - could be done at other time or place in code
            .onAppear {
                data[0].offset = CGSize.zero
                data[1].offset = CGSize(width: (data[0].size + data[1].size) / 2, height: 0 )
                    
                var alpha = CGFloat.zero
                
                for i in 2..<data.count {
                                    
                    // sides of the triangle from circle center points
                    let c = (data[0].size + data[i-1].size) / 2
                    let b = (data[0].size + data[i].size) / 2
                    let a = (data[i-1].size + data[i].size) / 2
                    
                    alpha += calculateAlpha(a, b, c)
                                    
                    let x = cos(alpha) * b
                    let y = sin(alpha) * b
                    
                    data[i].offset = CGSize(width: x, height: -y )
                }
            }
        }
        
        // Calculate alpha from sides - 1. Cosine theorem
        func calculateAlpha(_ a: CGFloat, _ b: CGFloat, _ c: CGFloat) -> CGFloat {
            return acos(
                ( pow(a, 2) - pow(b, 2) - pow(c, 2) )
                /
                ( -2 * b * c ) )
    
        }
        
    }
    
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
        }
    }
    
    Login or Signup to reply.
  2. ok you motivated me 🙂 here comes the extended version

    • standalone struct
    • adapts to parent !!
    • accepts the data, spacing, start angle, direction (clockwise/counterclockwise)

    the frame ist just to show adapt to parent size:

    enter image description here

    you call it like this:

    struct ContentView: View {
        
        // graph data
        @State private var data: [DataItem] = [
            DataItem(title: "chrome", size: 180, color: .green),
            DataItem(title: "firefox", size: 60, color: .red),
            DataItem(title: "safari", size: 90, color: .blue),
            DataItem(title: "edge", size: 30, color: .orange),
            DataItem(title: "ie", size: 50, color: .yellow),
            DataItem(title: "chrome", size: 120, color: .green),
            DataItem(title: "firefox", size: 60, color: .red),
            DataItem(title: "safari", size: 90, color: .blue),
            DataItem(title: "edge", size: 30, color: .orange),
            DataItem(title: "opera", size: 25, color: .mint)
        ]
        
        
        var body: some View {
            
            BubbleView(data: $data, spacing: 0, startAngle: 180, clockwise: true)
                .font(.caption)
                .frame(width: 300, height: 400)
                .border(Color.red)
            
        }
    }
    

    And here is the code:

    struct DataItem: Identifiable {
        var id = UUID()
        var title: String
        var size: CGFloat
        var color: Color
        var offset = CGSize.zero
    }
    
    struct BubbleView: View {
        
        @Binding var data: [DataItem]
        
        // Spacing between bubbles
        var spacing: CGFloat
        
        // startAngle in degrees -360 to 360 from left horizontal
        var startAngle: Int
        
        // direction
        var clockwise: Bool
        
        struct ViewSize {
            var xMin: CGFloat = 0
            var xMax: CGFloat = 0
            var yMin: CGFloat = 0
            var yMax: CGFloat = 0
        }
        
        @State private var mySize = ViewSize()
        
        var body: some View {
    
            let xSize = (mySize.xMax - mySize.xMin) == 0 ? 1 : (mySize.xMax - mySize.xMin)
            let ySize = (mySize.yMax - mySize.yMin) == 0 ? 1 : (mySize.yMax - mySize.yMin)
    
            GeometryReader { geo in
                
                let xScale = geo.size.width / xSize
                let yScale = geo.size.height / ySize
                let scale = min(xScale, yScale)
                         
                ZStack {
                    ForEach(data, id: .id) { item in
                        ZStack {
                            Circle()
                                .frame(width: CGFloat(item.size) * scale,
                                       height: CGFloat(item.size) * scale)
                                .foregroundColor(item.color)
                            Text(item.title)
                        }
                        .offset(x: item.offset.width * scale, y: item.offset.height * scale)
                    }
                }
                .offset(x: xOffset() * scale, y: yOffset() * scale)
            }
            .onAppear {
                setOffets()
                mySize = absoluteSize()
            }
        }
        
        
        // taken out of main for compiler complexity issue
        func xOffset() -> CGFloat {
            let size = data[0].size
            let xOffset = mySize.xMin + size / 2
            return -xOffset
        }
        
        func yOffset() -> CGFloat {
            let size = data[0].size
            let yOffset = mySize.yMin + size / 2
            return -yOffset
        }
    
        
        // calculate and set the offsets
        func setOffets() {
            if data.isEmpty { return }
            // first circle
            data[0].offset = CGSize.zero
            
            if data.count < 2 { return }
            // second circle
            let b = (data[0].size + data[1].size) / 2 + spacing
            
            // start Angle
            var alpha: CGFloat = CGFloat(startAngle) / 180 * CGFloat.pi
            
            data[1].offset = CGSize(width:  cos(alpha) * b,
                                    height: sin(alpha) * b)
            
            // other circles
            for i in 2..<data.count {
                
                // sides of the triangle from circle center points
                let c = (data[0].size + data[i-1].size) / 2 + spacing
                let b = (data[0].size + data[i].size) / 2 + spacing
                let a = (data[i-1].size + data[i].size) / 2 + spacing
                
                alpha += calculateAlpha(a, b, c) * (clockwise ? 1 : -1)
                
                let x = cos(alpha) * b
                let y = sin(alpha) * b
                
                data[i].offset = CGSize(width: x, height: y )
            }
        }
        
        // Calculate alpha from sides - 1. Cosine theorem
        func calculateAlpha(_ a: CGFloat, _ b: CGFloat, _ c: CGFloat) -> CGFloat {
            return acos(
                ( pow(a, 2) - pow(b, 2) - pow(c, 2) )
                /
                ( -2 * b * c ) )
            
        }
        
        // calculate max dimensions of offset view
        func absoluteSize() -> ViewSize {
            let radius = data[0].size / 2
            let initialSize = ViewSize(xMin: -radius, xMax: radius, yMin: -radius, yMax: radius)
            
            let maxSize = data.reduce(initialSize, { partialResult, item in
                let xMin = min(
                    partialResult.xMin,
                    item.offset.width - item.size / 2 - spacing
                )
                let xMax = max(
                    partialResult.xMax,
                    item.offset.width + item.size / 2 + spacing
                )
                let yMin = min(
                    partialResult.yMin,
                    item.offset.height - item.size / 2 - spacing
                )
                let yMax = max(
                    partialResult.yMax,
                    item.offset.height + item.size / 2 + spacing
                )
                return ViewSize(xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax)
            })
            return maxSize
        }
        
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search