The Util Designer
SwiftUI 自定議溫度計控件
Xcode 14.0, swift 5.7, iOS 16.0
2022-09-14
雖然SwiftUI也提供了很多內建的控件,也可以把UIKit的控件包裝成SwiftUI控件,但有時候不是不夠用,這次以一個溫度計控件來展示如何在SwiftUI做自定議的控件。
1. 先畫一個溫度計的外框,由一個大圓,一個小圓和一個長方形疊加在一起。
import SwiftUI

struct ThermometerShape : InsettableShape {
    var insetAmount: CGFloat = 0
    
    func path(in rect: CGRect) -> Path {
        let radius = min(2.0*rect.height/13.0, rect.width/2.0)
        let centerX = rect.width / 2.0
        let centerY = rect.height / 2.0 + 3.0 * radius + 1.0 * radius / 4.0 - radius
        let center = CGPoint(x : centerX, y : centerY)
        
        let originalX = center.x - 1.0 * radius / 2.0 + insetAmount
        let originalY = center.y - 5 * radius
        let origin = CGPoint(x : originalX, y : originalY)
        let rectWidth = radius - 2.0 * insetAmount
        let rectHeight = 5 * radius
        let rectSize = CGSize(width: rectWidth, height: rectHeight)
        let halfCircleRadius = 1.0 * rectWidth / 2.0
        let halfCircleCenterX = originalX + halfCircleRadius
        let halfCircleCenterY = originalY
        let halfCircleCenter = CGPoint(x : halfCircleCenterX, y : halfCircleCenterY)
        
        return Path { path in
            path.addArc(center: center, radius: radius - insetAmount, startAngle: .degrees(0.0), endAngle: .degrees(360), clockwise: false)
            path.addRect(CGRect(origin: origin, size: rectSize))
            path.addArc(center: halfCircleCenter, radius: halfCircleRadius, startAngle: .degrees(0.0), endAngle: .degrees(360.0), clockwise: false)
        }
    }
    
    func inset(by amount: CGFloat) -> some InsettableShape {
        var shape = self
        shape.insetAmount += amount
        return shape
    }
}

struct ThermometerView : View {
    var body: some View {
        ThermometerShape().stroke(.red, lineWidth: 2)
    }
}
2. 用這個外框在最低層做一個溫度計灰色的底色。
struct ThermometerView : View {
    var body: some View {
        ThermometerShape().foregroundColor(.gray)
    }
}
3. 在底色的上層疊加一個指示溫度的溫度層(就像溫度計里的水銀的作用):
struct ThermometerView : View {
    var body: some View {
        ZStack {
            ThermometerShape().foregroundColor(.gray)
            ThermometerShape().inset(by: 20).foregroundColor(.green)
        }
    }
}
4. 我們希望這個溫度層可以因應溫度的高低來拉長或壓低溫度層,可以使用Shape的trim,所以我們畫一個很寛的直線,並用溫度層的來為這條直線做切割,就可以達到這個效果(以下用了一條從下到上寛度為屏幕寛度的綠色長線,所以把原有的底色也擋住了):
struct TrimLine : InsettableShape {
    var insetAmount: CGFloat = 0
    
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x : rect.width/2, y: rect.height))
            path.addLine(to: CGPoint(x : rect.width/2, y: 0))
        }
    }
    
    func inset(by amount: CGFloat) -> some InsettableShape {
        var arc = self
        arc.insetAmount += amount
        return arc
    }
}
struct ThermometerView : View {
    var body: some View {
        GeometryReader { proxy in
            ZStack {
                ThermometerShape().foregroundColor(.gray)
                TrimLine()
                    .stroke(.green, lineWidth: proxy.size.width)
            }
        }
    }
}
5. 再來就是用Shape的clipShape來把長線切成溫度指示層的形狀:
struct ThermometerView : View {
    var body: some View {
        GeometryReader { proxy in
            ZStack {
                ThermometerShape().foregroundColor(.gray)
                TrimLine()
                    .stroke(.green, lineWidth: proxy.size.width)
                    .clipShape(
                        ThermometerShape()
                            .inset(by: 20)
                    )
            }
        }
    }
}
6. 從第5步的效果雖與第3點所作出的效果是一樣,哪為甚麼有這應麻煩呢?神奇的地方就是現在就可以使用trim為控制長綠條,比如只畫一半長度:
struct ThermometerView : View {
    var body: some View {
        GeometryReader { proxy in
            ZStack {
                ThermometerShape().foregroundColor(.gray)
                TrimLine()
                    .trim(from: 0.0, to: 0.5)
                    .stroke(.green, lineWidth: proxy.size.width)
                    .clipShape(
                        ThermometerShape()
                            .inset(by: 20)
                    )
            }
        }
    }
}
7. 然後使用DragGesture來控件溫度的高底:
struct ThermometerView : View {
    @State var current = 0.5
    @State var delta = 0.0
    var body: some View {
        
        GeometryReader { proxy in
            let dragGesture = DragGesture()
                .onChanged({ value in
                    delta = value.translation.height/proxy.size.height
                })
                .onEnded({ value in
                    current = max(0, min(1, current - delta))
                    delta = 0.0
                })
            
            ZStack {
                ThermometerShape().foregroundColor(.gray)
                TrimLine()
                    .trim(from: 0.0, to: current - delta)
                    .stroke(.green, lineWidth: proxy.size.width)
                    .clipShape(
                        ThermometerShape()
                            .inset(by: 20)
                    )
            }.gesture(dragGesture)
        }
    }
}
8. 還可以在因應不同的溫度顯示不同的顏色:
struct ThermometerView : View {
    @State var current = 0.5
    @State var delta = 0.0
    var body: some View {
        
        GeometryReader { proxy in
            let dragGesture = DragGesture()
                .onChanged({ value in
                    delta = value.translation.height/proxy.size.height
                })
                .onEnded({ value in
                    current = max(0, min(1, current - delta))
                    delta = 0.0
                })
            
            ZStack {
                ThermometerShape().foregroundColor(.gray)
                TrimLine()
                    .trim(from: 0.0, to: current - delta)
                    .stroke(current - delta >= 0.3 ? .red : .green, lineWidth: proxy.size.width)
                    .clipShape(
                        ThermometerShape()
                            .inset(by: 20)
                    )
            }.gesture(dragGesture)
        }
    }
}
9. 最後把這個View打包成一個SwiftUI控件Thermometer,就可以重覆的使用:
struct Thermometer : View {
    @State var current = 0.5
    @State var delta = 0.0
    var body: some View {
        
        GeometryReader { proxy in
            let dragGesture = DragGesture()
                .onChanged({ value in
                    delta = value.translation.height/proxy.size.height
                })
                .onEnded({ value in
                    current = max(0, min(1, current - delta))
                    delta = 0.0
                })
            
            ZStack {
                ThermometerShape().foregroundColor(.gray)
                TrimLine()
                    .trim(from: 0.0, to: current - delta)
                    .stroke(current - delta >= 0.3 ? .red : .green, lineWidth: proxy.size.width)
                    .clipShape(
                        ThermometerShape()
                            .inset(by: 10)
                    )
            }.gesture(dragGesture)
        }
    }
}

struct ThermometerView : View {
    var body: some View {
        VStack {
            Thermometer()
            Thermometer()
        }
    }
}