SwiftUI - 在给定的框架(打包气泡图)中适合不同大小(彼此)的 X 圈

SwiftUI - Fit X Circles of varying size (wrt each other) in a given frame (Packed Bubble Chart)

有没有什么方法可以用 SwiftUI 创建类似的东西(w/o 使用 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)
    ]

这里在测试数据中,“weight”表示应该是哪一项larger/smaller。

我能想到的一种方法是在给定视图中使用相对于父级大小的 X 圆圈。但这本身会给定位和确保圆圈不相互接触或重叠带来问题。

这里不知道SpriteKit的用法?可以使用它还是可以仅使用 SwiftUI 组件来实现?

这很有趣,回到学校数学:) 这是我的结果。小圆圈都围绕第一个大圆圈对齐,我没有解决你图片中“opera”的定位问题,好像是不小心。

代码如下: 这是一个起点,没有安全检查

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()
    }
}

好的,你激励了我:) 加长版来了

  • 独立结构
  • 适应 parent !!
  • 接受数据,间距,起始角度,方向(clockwise/counterclockwise)

框架只是为了适应 parent 尺寸:

你这样称呼它:

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

这是代码:

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
    }
    
}