diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f4cbbce --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Chen-Hai Teng + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift index 139dcd0..79d3d26 100644 --- a/Package.swift +++ b/Package.swift @@ -5,8 +5,12 @@ import PackageDescription let package = Package( name: "Rings", + platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6)], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "CommonExts", + targets: ["CommonExts"]), .library( name: "Rings", targets: ["Rings"]), @@ -14,15 +18,24 @@ let package = Package( dependencies: [ // Dependencies declare other packages that this package depends on. // .package(url: /* package url */, from: "1.0.0"), + .package(name: "CoreGraphicsExtension", url: "https://github.com/chenhaiteng/CoreGraphicsExtension.git", from: "0.2.0"), + .package(name: "ArchimedeanSpiral", url: "https://github.com/chenhaiteng/ArchimedeanSpiral.git", from: "1.0.12") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target(name: "CommonExts", + dependencies: []), .target( name: "Rings", - dependencies: []), + dependencies: ["CoreGraphicsExtension", "CommonExts", "ArchimedeanSpiral"], + exclude: ["RingText.md", + "ClockIndex.md", + "ArchimedeanSpiralText.md", + "HandAiguille.md"]), .testTarget( name: "RingsTests", - dependencies: ["Rings"]), + dependencies: ["Rings", + "CoreGraphicsExtension", "CommonExts"]), ] ) diff --git a/README.md b/README.md index e72e3b3..74a47e3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,82 @@ -# Rings +# Rings ![GitHub](https://img.shields.io/github/license/chenhaiteng/Rings?style=plastic) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/chenhaiteng/rings) -A description of this package. +**Rings** is a collection of controls which have similar shapes of ring, circle... + +It includes following controls, click to see what it looks like: + +* **[RingText](#ringtext)** +* **[ClockIndex](#clockindex)** +* **[HandAiguille](#handaiguille)** +* **[ArchimedeanSpiralText](#archimedeanspiraltext)** + +and following functions are in progress: +* SphericText +* Knob + +--- +## Installation: +### Install with Swift Package Manager +#### - Add to Xcode: + +1. File > Swift Packages > Add Package Dependency... +2. Choose Project you want to add Rings +3. Paste repository https://github.com/chenhaiteng/Rings.git +4. Rules > Version: Up to Next Major 0.1.0 +It's can also apply Rules > Branch : main to access latest code. +If you want try some experimental features, you can also apply Rules > Branch : develop + +**Note:** It might need to link Rings to your target maunally. + +1. Open *Project Editor* by tap on root of project navigator +2. Choose the target you want to use Rings. +3. Choose **Build Phases**, and expand **Link Binary With Libraries** +4. Tap on **+** button, and choose Rings to add it. + +#### - Add to SPM package: +```swift +dependencies: [ + .package(name: "Rings", url: "https://github.com/chenhaiteng/Rings.git", from: "0.1.0") + // To specify branch, use following statement to instead of. + // .package(name: "Rings", url: "https://github.com/chenhaiteng/Rings.git", .branch("branch_name")) +], +targets: [ + .target( + name: "MyPackage", + dependencies: ["Rings"]), +] +``` +--- + +## RingText + +### What it looks like +![RingDemo](https://user-images.githubusercontent.com/1284944/115984682-fb26a700-a5da-11eb-8a59-a1554ec41bdf.gif) + +### ![How to use it](Sources/Rings/RingText.md) + +## ClockIndex + +### What it looks like +![ClockIndex Demo Classic](https://user-images.githubusercontent.com/1284944/116664495-26d6d200-a9cb-11eb-906c-7ffe659dcfbc.gif) + +earchly_clock_demo + +### ![How to use it](Sources/Rings/ClockIndex.md) + +## HandAiguille + +### What it looks like: +![HandAguille](https://user-images.githubusercontent.com/1284944/118101511-47128200-b40a-11eb-870f-90ac2f2a302a.gif) + +### ![How to use it](Sources/Rings/HandAiguille.md) + +## ArchimedeanSpiralText + +### What it looks like: +![ArchimedeanSpiralTextDemo](https://user-images.githubusercontent.com/1284944/117950922-3ef10e80-b346-11eb-9da1-50b0f87990a2.gif) + +### ![How to use it](Sources/Rings/ArchimedeanSpiralText.md) + +--- +# License +Rings is released under the [MIT License](LICENSE). diff --git a/Sources/CommonExts/CollectionExts.swift b/Sources/CommonExts/CollectionExts.swift new file mode 100644 index 0000000..e6a5020 --- /dev/null +++ b/Sources/CommonExts/CollectionExts.swift @@ -0,0 +1,15 @@ +// +// CollectionExts.swift +// +// Refer to https://stackoverflow.com/a/37225027/505763 +// Created by Chen Hai Teng on 4/22/21. +// + +import Foundation + +public extension Collection where Indices.Iterator.Element == Index { + subscript (safe index: Index) -> Iterator.Element? { + return indices.contains(index) ? self[index] : nil + } +} + diff --git a/Sources/CommonExts/ViewExts.swift b/Sources/CommonExts/ViewExts.swift new file mode 100644 index 0000000..67b76e1 --- /dev/null +++ b/Sources/CommonExts/ViewExts.swift @@ -0,0 +1,19 @@ +// +// ViewExts.swift +// +// Refer to https://stackoverflow.com/a/57685253/505763 +// Created by Chen Hai Teng on 4/22/21. +// + +import SwiftUI + +public extension View { + @ViewBuilder + func `if`(_ conditional: Bool, content: (Self)->Content) -> some View { + if(conditional) { + content(self) + } else { + self + } + } +} diff --git a/Sources/Rings/ArchimedeanSpiralPath.swift b/Sources/Rings/ArchimedeanSpiralPath.swift new file mode 100644 index 0000000..ecf8086 --- /dev/null +++ b/Sources/Rings/ArchimedeanSpiralPath.swift @@ -0,0 +1,120 @@ +// +// ArchimedeanSpiralPath.swift +// Rings +// +// Created by Chen Hai Teng on 3/5/21. +// + +import SwiftUI +import ArchimedeanSpiral +import CoreGraphicsExtension + +public struct ArchimedeanSpiralPath: View { + var radiusSpacing: Double + var innerRadius: Double + var gap: Double + var ptCount: Int + var spiralDesc :ArchimedeanSpiral + var startAngle: CGAngle = CGAngle.zero + + private lazy var points: [CGPoint] = spiralDesc.equidistantPoints(start: startAngle, num: ptCount).map { polar in + polar.cgpoint + } + + init(_ innerRadius: Double = 12.0, spacing: Double = 10.0, gap: Double = 5.0, count: Int = 100) { + self.radiusSpacing = spacing + self.innerRadius = innerRadius + self.gap = gap + self.ptCount = count + spiralDesc = ArchimedeanSpiral(innerRadius: self.innerRadius, radiusSpacing: self.radiusSpacing, spacing: self.gap) + + } + + func getPoints()-> [CGPoint] { + var mutateSelf = self + return mutateSelf.points + } + + public var body: some View { + GeometryReader { geo in + Path { path in + let midX = geo.size.width/2 + let midY = geo.size.height/2 + path.move(to: CGPoint( + x: midX, + y: midY + )) + getPoints().forEach { pt in + let next = CGPoint(x: pt.x + midX, y: pt.y + midY) + path.addLine(to: next) + } + }.stroke(Color.red) + } + } +} + +extension ArchimedeanSpiralPath : Adjustable { + func start(_ angle: CGAngle) -> Self { + setProperty { tmp in + tmp.startAngle = angle + } + } +} + +struct ArchimedeanSpiralPathDemo : View { + @State var radiusSpacing: Double = 10.0 + @State var innerR: Double = 5.0 + @State var spacing: Double = 25.0 + @State var count: Double = 100.0 + @State var startAt: Double = 0.0 + @State var showInner: Bool = false + @State var showRadius: Bool = false + var body: some View { + VStack { + ZStack { + GeometryReader { geo in + let midX = geo.size.width/2 + let midY = geo.size.height/2 + ArchimedeanSpiralPath(innerR, spacing: radiusSpacing, gap: spacing, count: Int(count)).start(CGAngle.degrees(startAt)) + Group { + if (showInner) { + Path { p in + p.move(to: CGPoint(x: midX, y: midY)) + p.addLine(to: CGPoint(x: midX + CGFloat(innerR), y: midY)) + }.stroke(style: StrokeStyle(lineWidth: 2.0, lineCap: .butt, lineJoin: .miter, miterLimit: 0, dash: [3,1], dashPhase: 0)) + } + if(showRadius) { + Path { p in + p.move(to: CGPoint(x: midX + CGFloat(innerR), y: midY)) + p.addLine(to: CGPoint(x: midX + CGFloat(innerR) + CGFloat(radiusSpacing), y: midY)) + }.stroke(Color.blue, style: StrokeStyle(lineWidth: 2.0, lineCap: .butt, lineJoin: .miter, miterLimit: 0, dash: [3,1], dashPhase: 0)) + } + } + } + } + Slider(value: $startAt, in: 0.0...360.0) { + Text("start at (\(startAt))") + }.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)) + Slider(value: $radiusSpacing, in: 10.0...50.0) { + Toggle("", isOn: $showRadius) + Text("radius spacing (\(radiusSpacing))") + }.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)) + Slider(value: $innerR, in: 10.0...50.0) { + Toggle("", isOn: $showInner) + Text("inner radius(\(innerR))") + }.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)) + Slider(value: $spacing, in: 10.0...50.0) { + Text("points spacing(\(spacing))") + }.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)) + Slider(value: $count, in: 100.0...200.0, step: 10.0) { + Text("count(\(Int(count)))") + }.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)) + } + } +} + +struct SwiftUIView_Previews: PreviewProvider { + static var previews: some View { + ArchimedeanSpiralPathDemo() + } +} diff --git a/Sources/Rings/ArchimedeanSpiralText.md b/Sources/Rings/ArchimedeanSpiralText.md new file mode 100644 index 0000000..620c849 --- /dev/null +++ b/Sources/Rings/ArchimedeanSpiralText.md @@ -0,0 +1,24 @@ +# ArchimedeanSpiralText + +## Preview: + +![ArchimedeanSpiralTextDemo](https://user-images.githubusercontent.com/1284944/117950922-3ef10e80-b346-11eb-9da1-50b0f87990a2.gif) + +### Usage: +```swift +// Create text along archimedean spiral +ArchimedeanSpiralText("My Archimedean Spiral") + +// Setup Archimedean Spiral parameters +ArchimedeanSpiralText("My Archimedean Spiral") + .gap(10.0) // To setup the distance between two calculated points. + .innerRadius(15.0) // To specify the start radius of an archimedean spiral + .spacing(radiusSpacing) // To adjust the constant separation distance between intersection points measured from the origin. + +// Update the text appearance +ArchimedeanSpiralText() + .text("My Archimedean Spiral") // Modifing text content + .textDirection(direction) // Specifing the direction of text. + .textColor(color) // Change text color + .font(font) // Change font and text size +``` diff --git a/Sources/Rings/ArchimedeanSpiralText.swift b/Sources/Rings/ArchimedeanSpiralText.swift new file mode 100644 index 0000000..d7d80fd --- /dev/null +++ b/Sources/Rings/ArchimedeanSpiralText.swift @@ -0,0 +1,234 @@ +// +// ArchimedeanSpiralText.swift +// Rings +// +// Created by Chen Hai Teng on 3/5/21. +// + +import SwiftUI +import ArchimedeanSpiral +import CoreGraphicsExtension + +public enum TextDirection { + case Top, Bottom, Left, Right + var cgangle: CGAngle { + switch self { + case .Top: + return CGAngle.degrees(270.0) + case .Bottom: + return CGAngle.degrees(90.0) + case .Left: + return CGAngle.degrees(0.0) + case .Right: + return CGAngle.degrees(180.0) + } + } +} + +public struct ArchimedeanSpiralText: View { + private var radiusSpacing: Double + private var innerRadius: Double + private var gap: Double + private var textDirection: TextDirection = .Top + private var font = Font.system(size: 20.0) + private var textColor: Color = Color.red + + private var text: String { + didSet { + chars = Array(text.enumerated()) + } + } + + private var chars: [(offset: Int, element:Character)] { + didSet { + + } + } + + private var textPoints: [CGPolarPoint] = [] + + public init(_ innerRadius: Double = 12.0, spacing: Double = 10.0, gap: Double = 5.0, text: String = "") { + self.radiusSpacing = spacing + self.innerRadius = innerRadius + self.gap = gap + self.text = text + self.chars = Array(text.enumerated()) + updateTextPoints() + } + + private mutating func updateTextPoints() { + let spiral = ArchimedeanSpiral(innerRadius: self.innerRadius, radiusSpacing: self.radiusSpacing, spacing: self.gap) + textPoints = spiral.equidistantPoints(start: CGAngle.zero, num: self.chars.count) + } + + public var body: some View { + GeometryReader { geo in + ZStack { + ForEach(chars, id: \.self.offset) { (offset, element) in + let pt = self.textPoints[offset].cgpoint + let textPt = CGPoint(x: pt.x, y: pt.y) + let rotation = (self.textPoints[offset].cgangle + textDirection.cgangle).toAngle() + Text(String(element)) + .rotationEffect(rotation) + .offset(x: textPt.x, y: textPt.y) + .foregroundColor(textColor) + .font(font) + + } + }.frame(width: geo.size.width, height: geo.size.height, alignment: .center) + } + } +} + +extension ArchimedeanSpiralText: Adjustable { + public func spacing(_ space: T) -> Self { + setProperty { tmp in + tmp.radiusSpacing = Double(space) + tmp.updateTextPoints() + } + } + + public func innerRadius(_ radius: T) -> Self { + setProperty { tmp in + tmp.innerRadius = Double(radius) + tmp.updateTextPoints() + } + } + + public func gap(_ gap: T) -> Self { + setProperty { tmp in + tmp.gap = Double(gap) + tmp.updateTextPoints() + } + } + + public func text(_ text: String) -> Self { + setProperty { tmp in + tmp.text = text + tmp.chars = Array(text.enumerated()) + tmp.updateTextPoints() + } + } + + public func textDirection(_ direction: TextDirection) -> Self { + setProperty { tmp in + tmp.textDirection = direction + } + } + + public func font(_ font: Font) -> Self { + setProperty { tmp in + tmp.font = font + } + } + + public func textColor(_ color: Color) -> Self { + setProperty { tmp in + tmp.textColor = color + } + } +} + +struct SegmentedPicker: ViewModifier { + func body(content: Content) -> some View { + #if os(watchOS) + content + #else + content.pickerStyle(SegmentedPickerStyle()).padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 7)) + #endif + } +} + +struct ColoredPicker: ViewModifier { + @Binding var selection: Color + func body(content: Content) -> some View { + #if os(macOS) || os(iOS) + if #available(macOS 11.0, *) { + ColorPicker("", selection: _selection) + } else { + content.modifier(SegmentedPicker()) + } + #else + content.modifier(SegmentedPicker()) + #endif + } +} + +extension Picker { + func segmented() -> some View { + modifier(SegmentedPicker()) + } + + func colorPicker(_ selection: Binding) -> some View { + modifier(ColoredPicker(selection: selection)) + } +} + +struct ArchimedeanSpiralTextDemo : View { + private let demoText = "1234567890abcdefgABCDEFG♩♪♫♬" + @State var radiusSpacing: Double = 20.0 + @State var innerR: Double = 25.0 + @State var gap: Double = 25.0 + @State var textLength: Double = 15.0 + @State var direction: TextDirection = TextDirection.Top + @State var color: Color = .white + @State var font: Font = .system(.body) + + public var body: some View { + VStack { + let enabled = String(demoText.prefix(Int(textLength))) + let disabled = String(demoText.suffix(demoText.count - Int(textLength))) + ArchimedeanSpiralText() + .gap(gap) + .innerRadius(innerR) + .spacing(radiusSpacing) + .text(enabled) + .textDirection(direction) + .textColor(color) + .font(font) + Slider(value: $textLength, in: 1.0...28.0, step: 1.0) { + Text(enabled).foregroundColor(.white) + Text(disabled).foregroundColor(.gray) + }.padding(EdgeInsets(top: 0.0, leading: 10.0, bottom: 0.0, trailing: 10.0)) + HStack { + VStack { + Text("direction forward to center") + Picker("", selection: $direction) { + Text("top").tag(TextDirection.Top) + Text("bottom").tag(TextDirection.Bottom) + Text("right").tag(TextDirection.Right) + Text("left").tag(TextDirection.Left) + }.segmented() + Text("text color") + Picker("", selection: $color) { + Text("White").tag(Color.white) + Text("Red").tag(Color.red) + Text("Blue").tag(Color.blue) + Text("Green").tag(Color.green) + }.colorPicker($color) + Picker("Font", selection: $font) { + Text(".body").tag(Font.system(.body)) + Text(".caption").tag(Font.system(.caption)) + Text("Zapfino(10)").tag(Font.custom("Zapfino", size: 10.0)) + }.segmented() + } + VStack { + Slider(value: $innerR, in: 0.0...30.0, step: 5.0) { + Text("Inner Radius: \(innerR)") + }.padding(EdgeInsets(top: 0.0, leading: 10.0, bottom: 0.0, trailing: 10.0)) + Slider(value: $radiusSpacing, in: 10.0...80.0, step: 5.0) { + Text("Radius Spacing: \(radiusSpacing)") + }.padding(EdgeInsets(top: 0.0, leading: 10.0, bottom: 0.0, trailing: 10.0)) + Slider(value: $gap, in: 10.0...40.0, step: 3.0) { + Text("Gap:\(gap)") + }.padding(EdgeInsets(top: 0.0, leading: 10.0, bottom: 0.0, trailing: 10.0)) + } + } + } + } +} + +struct ArchimedeanSpiralText_Previews: PreviewProvider { + public static var previews: some View { + ArchimedeanSpiralTextDemo() + } +} diff --git a/Sources/Rings/ClockIndex.md b/Sources/Rings/ClockIndex.md new file mode 100644 index 0000000..648e9f0 --- /dev/null +++ b/Sources/Rings/ClockIndex.md @@ -0,0 +1,22 @@ +## ClockIndex + +![ClockIndex Demo Classic](https://user-images.githubusercontent.com/1284944/116664495-26d6d200-a9cb-11eb-906c-7ffe659dcfbc.gif) + +earchly_clock_demo + +### Usage: + +```Swift + // Default clock index with radius 50.0 + ClockIndex().radius(50.0) + + // Modify hour index style with radius. + ClockIndex().hourIndexStyle(StrokeStyle(lineWidth: 5.0).hourStyle(with: indexRadius)) + + // Custom hour marker text + ClockIndex(textMarkers: ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"]) + + // Show/Hide hour index and minutes track + ClockIndex().showIndex(shouldShowIndex) +``` + diff --git a/Sources/Rings/ClockIndex.swift b/Sources/Rings/ClockIndex.swift new file mode 100644 index 0000000..0b4be9f --- /dev/null +++ b/Sources/Rings/ClockIndex.swift @@ -0,0 +1,219 @@ +// +// ClockIndex.swift +// +// +// Created by Chen Hai Teng on 4/22/21. +// + +import SwiftUI +import CoreGraphicsExtension +import CommonExts + +public enum ClockIndexError: Error { + case outOfBounds(String) +} + +public let defaultTextMarker = ["1","2","3","4","5","6","7","8","9","10","11","12"] + +public let defaultMarkers: [AnyView] = defaultTextMarker.map { num -> AnyView in + AnyView(Text(num)) +} + +public let defaultRadius: CGFloat = 80.0 + +public let classicHourStyle: StrokeStyle = StrokeStyle(lineWidth: 5.0, lineCap: CGLineCap.butt, lineJoin: CGLineJoin.miter, miterLimit: 0.0, dash: [0, CGFloat.pi*100/6], dashPhase: 0.0) + +public let classicMinStyle: StrokeStyle = StrokeStyle(lineWidth: 2.0, lineCap: CGLineCap.butt, lineJoin: CGLineJoin.miter, miterLimit: 0.0, dash: [0, CGFloat.pi*100/36], dashPhase: 0.0) + +public extension StrokeStyle { + func hourStyle(with radius: T) -> Self { + StrokeStyle(lineWidth: self.lineWidth, lineCap: self.lineCap, lineJoin: self.lineJoin, miterLimit: self.miterLimit, dash: [0, CGFloat.pi*CGFloat(radius)/6], dashPhase: self.dashPhase) + } + + func minuteStyle(with radius: T) -> Self { + StrokeStyle(lineWidth: self.lineWidth, lineCap: self.lineCap, lineJoin: self.lineJoin, miterLimit: self.miterLimit, dash: [0, CGFloat.pi*CGFloat(radius)/36], dashPhase: self.dashPhase) + } +} + +public struct ClockIndex: View { + + private var hourMarkers: [AnyView] = defaultMarkers + private var textColor: Color = .white + private var radius: CGFloat = defaultRadius + private var showBlueprint: Bool = false + + private var showIndex: Bool = true + private var indexColor: Color = .white + // hours index + private var hourIndexStyle: StrokeStyle = StrokeStyle() + private var hourIndexRadius: CGFloat = defaultRadius + 15.0 + private var hourIndexColor: Color = .white + // minutes index + private var minIndexStyle: StrokeStyle = StrokeStyle() + private var minIndexRadius: CGFloat = defaultRadius + 10.0 + private var minIndexColor: Color = .white + + public init(textMarkers: [String] = defaultTextMarker, color: Color = .white) throws { + guard textMarkers.count == 12 else { + throw ClockIndexError.outOfBounds("The number of markers whould be 12.") + } + textColor = color + hourMarkers = textMarkers.map({ text -> AnyView in + AnyView(Text(text).foregroundColor(textColor)) + }) + } + + public init(_ markers: [AnyView]) throws { + guard markers.count == 12 else { + throw ClockIndexError.outOfBounds("The number of markers whould be 12.") + } + hourMarkers = markers + } + + public var body: some View { + GeometryReader { geo in + ZStack { + if(showIndex) { + Circle().stroke(style: hourIndexStyle).frame(width: 2*hourIndexRadius, height: 2*hourIndexRadius, alignment: .center).foregroundColor(hourIndexColor) + Circle().stroke(style: minIndexStyle).frame(width: 2*minIndexRadius, height: 2*minIndexRadius, alignment: .center).foregroundColor(minIndexColor) + } + ForEach(0..<12) { index in + let polarPt = CGPolarPoint(radius: radius, angle: CGAngle.pi/6*CGFloat(index) - CGAngle.pi/3) + Sizing { + hourMarkers[index].if(showBlueprint) { content in + content.border(Color.blue, width: 1) + } + }.offset(x: polarPt.cgpoint.x, y: polarPt.cgpoint.y) + } + if(showBlueprint) { + Path { path in + path.addEllipse(in: CGRect(origin: CGPoint(x: geo.size.width/2 - radius, y: geo.size.height/2 - radius), size: CGSize(width: 2*radius, height: 2*radius)), transform: CGAffineTransform.identity) + }.stroke(Color.blue, lineWidth: 1.0) + } + }.frame(width: geo.size.width, height: geo.size.height, alignment: .center).if(showBlueprint){ content in + content.border(Color.blue, width: 1) + } + } + } +} + +extension ClockIndex { + func setProperty(_ setBlock: (_ clockIndex: inout Self) -> Void) -> Self { + let result = _setProperty(content: self) { (tmp :inout Self) in + setBlock(&tmp) + return tmp + } + return result + } + + public func radius(_ r: T) -> Self { + setProperty { tmp in + tmp.radius = CGFloat(r) + } + } + public func showBlueprint(_ show: Bool) -> Self { + setProperty { tmp in + tmp.showBlueprint = show + } + } + + public func hourIndexStyle(_ style: StrokeStyle, color: Color? = nil) -> Self { + setProperty { tmp in + tmp.hourIndexStyle = style + tmp.hourIndexColor = color ?? tmp.hourIndexColor + } + } + + public func hourIndexRadius(_ r: T) -> Self { + setProperty { tmp in + tmp.hourIndexRadius = CGFloat(r) + } + } + + public func minIndexStyle(_ style: StrokeStyle, color: Color? = nil) -> Self { + setProperty { tmp in + tmp.minIndexStyle = style + tmp.minIndexColor = color ?? tmp.minIndexColor + } + } + + public func minIndexRadius(_ r: T) -> Self { + setProperty { tmp in + tmp.minIndexRadius = CGFloat(r) + } + } + + public func showIndex(_ show: Bool = true) -> Self { + setProperty { tmp in + tmp.showIndex = show + } + } +} + +//Previews +struct ClockPreviewClassic : View { + @State var showBlueprint: Bool = false + @State var showIndex: Bool = true + @State var indexRadius: CGFloat = 60 + @State var indexRadius2: CGFloat = 100 + var body: some View { + VStack { + Spacer(minLength: 10.0) + Text("Classic Clocks") + HStack { + VStack { + try? ClockIndex().radius(50.0).showBlueprint(showBlueprint).hourIndexStyle(StrokeStyle(lineWidth: 5.0).hourStyle(with: indexRadius)).hourIndexRadius(indexRadius) + .minIndexStyle(StrokeStyle().minuteStyle(with: indexRadius)).minIndexRadius(indexRadius) + .showIndex(showIndex) + HStack { + Slider(value: $indexRadius, in: 30...100, step: 5.0) + Text("\(indexRadius)") + } + } + VStack { + try? ClockIndex(textMarkers: ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"]).showBlueprint(showBlueprint).hourIndexStyle(StrokeStyle(lineWidth: 5.0).hourStyle(with: indexRadius2+5), color: .red).hourIndexRadius(indexRadius2+5) + .minIndexStyle(StrokeStyle().minuteStyle(with: indexRadius2-5), color: .blue).minIndexRadius(indexRadius2-5) + .showIndex(showIndex) + HStack { + Slider(value: $indexRadius2, in: 60...150, step: 5.0) + Text("\(indexRadius2)") + } + } + } + Toggle("Show Index", isOn: $showIndex) + Spacer(minLength: 5.0) + Divider() + Toggle("Blue Print", isOn: $showBlueprint) + Spacer(minLength: 10.0) + } + } +} + +struct ClockPreviewEarthlyBranches : View { + @State var showBlueprint: Bool = false + var body: some View { + VStack { + Spacer(minLength: 10.0) + Text("Earchly Branches Clocks") + HStack { + try? ClockIndex(textMarkers: [".", "丑", ".", "寅", ".", "卯", ".", "辰", ".", "巳", ".", "子"], color: .red).showBlueprint(showBlueprint) + + try? ClockIndex(textMarkers: [".", "未", ".", "申", ".", "酉", ".", "戌", ".", "亥", ".", "午"]).showBlueprint(showBlueprint) + } + Toggle("Blue Print", isOn: $showBlueprint) + Spacer(minLength: 10.0) + } + } +} + +struct ClockIndex_Previews: PreviewProvider { + static var previews: some View { + Group { + ClockPreviewClassic() + } + Group { + ClockPreviewEarthlyBranches() + } + } +} + diff --git a/Sources/Rings/HandAiguille.md b/Sources/Rings/HandAiguille.md new file mode 100644 index 0000000..0f6354c --- /dev/null +++ b/Sources/Rings/HandAiguille.md @@ -0,0 +1,25 @@ +## HandAiguille + +### Preview +https://user-images.githubusercontent.com/1284944/117106480-83aeff80-adb2-11eb-8e82-d77d9569dcca.mov + +### Usage: +```swift + // Create empty hand aiguille with default size, and set the hand aiguille background red + HandAiguille() { + }.handBackground(Color.red) + + // Create empty hand aiguille with time provider, and specify its time unit. + @State var hourProvider: Double = 0.0 + HandAiguille(time: $hourProvider, unit: .hour) { + }.handBackgroudn(Color.red) + + // Create hand aiguille with Image + @State var secsProvider: Double = 0.0 + HandAiguille(time: $secsProvider) { + Image("SpadeHand") + } + + // Create apple watch style hand + HandFactory.standard.makeAppleWatchStyleHand(time: $secsProvider) +``` diff --git a/Sources/Rings/HandAiguille.swift b/Sources/Rings/HandAiguille.swift new file mode 100644 index 0000000..5d7818a --- /dev/null +++ b/Sources/Rings/HandAiguille.swift @@ -0,0 +1,210 @@ +// +// HandAiguille.swift +// +// +// Created by Chen Hai Teng on 5/4/21. +// + +import SwiftUI +import Foundation + +public enum TimeUnit { + case hour, minute, second +} + +extension TimeUnit { + public typealias RawValue = Calendar.Component + public init?(rawValue: RawValue) { + switch rawValue { + case .hour: self = Self.hour + case .minute: self = Self.minute + case .second: self = Self.second + default: return nil + } + } +} + +public struct HandAiguille : View { + + @Binding private var time: T + @State private var angle: Angle = Angle() + + private let content: () -> Content + private var handSize: CGSize + private var offset: CGFloat + private var timeUnit: TimeUnit + + private var showBlueprint: Bool = false + private var handBackground: AnyView = AnyView(Color.clear) + + public init(size: CGSize = CGSize(width: 3.0, height: 50.0), offset: T = 1.5, time: Binding = .constant(0), unit: TimeUnit = .second, @ViewBuilder content: @escaping () -> Content) { + self.handSize = size + self.offset = CGFloat(offset) + _time = time + timeUnit = unit + self.content = content + } + + public var body: some View { + GeometryReader { geo in + ZStack { + let yoffset:CGFloat = handSize.height/2.0 - offset + if(showBlueprint) { + Circle().stroke().frame(width:geo.size.width, height: geo.size.height) + } + Path { p in + p.move(to: CGPoint(x: 0.0, y: geo.size.height/2.0)) + p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height/2.0)) + p.move(to: CGPoint(x: geo.size.width/2, y: 0.0)) + p.addLine(to: CGPoint(x: geo.size.width/2, y: geo.size.height)) + }.if(showBlueprint) { p in + p.stroke(Color.blue) + } + if(content() is EmptyView) { + handBackground.frame(width: handSize.width, height: handSize.height, alignment: .center).offset(y: -yoffset).rotationEffect(angleOfTime(time)) + } else { + content().frame(width: handSize.width, height: handSize.height, alignment: .center).offset(y: -yoffset).rotationEffect(angleOfTime(time)) + } + } + } + } + + private func angleOfTime(_ time: T) -> Angle { + let t = Double(time) + switch timeUnit { + case .hour: + let r = t.truncatingRemainder(dividingBy: 12.0) + return Angle(degrees: r * 30.0) + case .minute, .second: + let r = + t.truncatingRemainder(dividingBy: 60.0) + return Angle(degrees: r * 6.0) + } + } +} + +extension HandAiguille { + func setProperty(_ setBlock: (_ handAiguille: inout Self) -> Void) -> Self { + let result = _setProperty(content: self) { (tmp :inout Self) in + setBlock(&tmp) + return tmp + } + return result + } + + public func blueprint(_ isOn:Bool = true) -> Self { + setProperty { tmp in + tmp.showBlueprint = isOn + } + } + + public func handBackground(_ background: Background) -> Self where Background : View { + setProperty{ tmp in + tmp.handBackground = AnyView(background) + } + } +} + +public struct HandFactory { + public static let standard = HandFactory() + private let rectRatio: CGFloat = 0.2 + public func makeAppleWatchStyleHand(size: CGSize = CGSize(width: 4.0, height: 60.0), timeProvider: Binding, unit: TimeUnit = .second) -> some View { + HandAiguille(size: size, offset: 1.5, time: timeProvider, unit: unit) { + VStack(spacing: 0) { + Capsule().stroke().frame(width: size.width) + Rectangle().frame(width: 2, height: 10, alignment: .center) + Circle().stroke().frame(width: 3, height: 3, alignment: .center) + } + } + } +} + + +struct RoundedBorder: ViewModifier { + var cornerRadius:CGFloat = 5 + var color = Color.white + var lineWidth:CGFloat = 1 + func body(content: Content) -> some View { + content.padding(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(color, lineWidth: lineWidth) + ) + } +} + +extension View { + func rounded(_ cornerRadius:CGFloat = 5, color:Color = .white, width:CGFloat = 1) -> some View { + modifier(RoundedBorder(cornerRadius: cornerRadius, color: color, lineWidth: width)) + } +} + +struct AppleStyleHandPreview: View { + @State var emulateTime: CGFloat = 0.0 + @State var showBlueprint: Bool = false + @State var offset: CGFloat = 1.5 + @State var unit: TimeUnit = .second + + var body: some View { + VStack { + HStack { + ZStack { + HandAiguille(size: CGSize(width: 4.0, height: 50.0), offset: offset, time: $emulateTime, unit: unit) { + GeometryReader { geo in + VStack(spacing: 0) { + Capsule().stroke() + Rectangle().frame(width: 2, height: 10, alignment: .center) + Circle().stroke().frame(width: 3, height: 3, alignment: .center) + } + } + }.blueprint(showBlueprint).frame(width: 100, height: 100, alignment: .center) + } + ZStack { + HandFactory.standard.makeAppleWatchStyleHand(size: CGSize(width: 5.0, height: 80.0),timeProvider: $emulateTime, unit: .hour).frame(width: 120, height: 120, alignment: .center) + HandFactory.standard.makeAppleWatchStyleHand(size: CGSize(width: 4.0, height: 60.0),timeProvider: $emulateTime, unit: .minute).frame(width: 120, height: 120, alignment: .center) + } + } + + HStack { + Spacer(minLength: 10) + Slider(value: $emulateTime, in: 0.0...60.0, minimumValueLabel: Text("0.0"), maximumValueLabel: Text("60.0")) { + + Text("time") + .rounded() + } + Spacer(minLength: 10) + } + HStack { + Spacer(minLength: 10) + Slider(value: $offset, in: 0.0...20.0, step: 0.5, minimumValueLabel: Text("0.0"), maximumValueLabel: Text("20.0")) { + Text("offset").rounded() + } + Spacer(minLength: 10) + } + HStack { + Spacer(minLength: 10) + #if os(watchOS) + Picker("Time Unit", selection: $unit) { + Text("hour").tag(TimeUnit.hour) + Text("minute").tag(TimeUnit.minute) + Text("second").tag(TimeUnit.second) + } + #else + Picker("Time Unit", selection: $unit) { + Text("hour").tag(TimeUnit.hour) + Text("minute").tag(TimeUnit.minute) + Text("second").tag(TimeUnit.second) + }.pickerStyle(SegmentedPickerStyle()) + #endif + Spacer(minLength: 10) + } + Toggle("show blueprint", isOn: $showBlueprint) + } + } +} + +struct HandAiguille_Previews: PreviewProvider { + static var previews: some View { + AppleStyleHandPreview().frame(width: 350, height: 350, alignment: .center) + } +} diff --git a/Sources/Rings/InternalUtilities.swift b/Sources/Rings/InternalUtilities.swift new file mode 100644 index 0000000..c967800 --- /dev/null +++ b/Sources/Rings/InternalUtilities.swift @@ -0,0 +1,26 @@ +// +// InternalUtilities.swift +// +// +// Created by Chen Hai Teng on 5/9/21. +// + +import Foundation + +internal func _setProperty(content: T, _ setBlock:(_ newContent: inout T) -> T) -> T { + var temp = content + return setBlock(&temp) +} + + +protocol Adjustable {} + +extension Adjustable { + func setProperty(_ setBlock: (_ text: inout Self) -> Void) -> Self { + let result = _setProperty(content: self) { (tmp :inout Self) in + setBlock(&tmp) + return tmp + } + return result + } +} diff --git a/Sources/Rings/RingText.md b/Sources/Rings/RingText.md new file mode 100644 index 0000000..92905ed --- /dev/null +++ b/Sources/Rings/RingText.md @@ -0,0 +1,23 @@ +## RingText + +![RingDemo](https://user-images.githubusercontent.com/1284944/115984682-fb26a700-a5da-11eb-8a59-a1554ec41bdf.gif) + +### Usage: + +```swift + // To layout the text "1234567890" along a circle with radius 40. + RingText(radius: 40.0, text: "1234567890") + + // To layout a list to words along a circle with radius 40. + RingText(radius: 40.0, words: ["1","2","3","4","5","6"]) + + // To create and setup RingText with rich features: + RingText(radius: 40.0, text: "1234567890") + .font(Font.custom("Apple Chancery", size: 16.0)) // Setup font and size + .begin(degrees: -90.0) // Modify the begining degrees + .end(degress: 90.0) // Modify the end degrees. + .textColor(.red) // Change the color of text, default is white. + + // Show blueprint of each text (For debug purpose) + RingText(radius: 40.0, text: "1234567890").showBlueprint(true) +``` diff --git a/Sources/Rings/RingText.swift b/Sources/Rings/RingText.swift new file mode 100644 index 0000000..4e64e08 --- /dev/null +++ b/Sources/Rings/RingText.swift @@ -0,0 +1,247 @@ +import SwiftUI +import CoreGraphicsExtension +import CommonExts + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public extension CGAngle { + func toAngle(offset radians: CGFloat = 0.0) -> Angle { + Angle(radians: Double(self.radians + radians)) + } +} + +private func _createCharacters(origin: [String], reversed:Bool = false) -> [String] { + var tmpWords = reversed ? origin.reversed() : origin + if(reversed) { + tmpWords = tmpWords.map({ word -> String in + String(word.reversed()) + }) + } + let text = (tmpWords.count == 1) ? tmpWords[0] : tmpWords.reduce(String()) { + result, element -> String in + result + element + " " + } + let characters = text.map(String.init) + return characters +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public struct RingText : View { + private var radius: Double + private var textColor: Color + private var textUpsideDown: Bool + private var textReversed: Bool + + private var char_spacing: Double + private var kerning_r: Double + private var beginRadians: Double + private var endRadians: Double + + private var font = Font.system(size: 20.0) + + private var originwords: [String] + private var characters: [String] + private var textPoints: [CGPolarPoint] = [] + + @State private var sizes: [CGSize] = [] + private var showBlueprint: Bool = false + + public init(radius: T, words: [String], color: Color = Color.white, upsideDown: Bool = false, reversed: Bool = false, begin: CGAngle = CGAngle.zero, end: CGAngle? = nil) { + self.radius = Double(radius) + self.textColor = color + self.textUpsideDown = upsideDown + self.textReversed = reversed + self.originwords = words + + characters = _createCharacters(origin: words, reversed: reversed) + + beginRadians = Double(begin.radians) + + let count = characters.count-1 + let ratio = CGFloat(count)/CGFloat(characters.count) + + endRadians = Double(end?.radians ?? 2*CGFloat.pi*ratio+begin.radians) + + char_spacing = (endRadians - beginRadians)/Double(count) + + kerning_r = acos(1.0 - pow(20.0/self.radius, 2)/2.0) + + textPoints = _createTextPoints() + } + + public init(radius: T, text: String, color: Color = Color.white, upsideDown: Bool = false, reversed: Bool = false, begin: CGAngle = CGAngle.zero) { + self.init(radius: radius, words: [text], color: color, upsideDown: upsideDown, reversed: reversed, begin: begin) + } + + private func _createTextPoints() -> [CGPolarPoint] { + return characters.enumerated().map { index, element -> CGPolarPoint in + return CGPolarPoint(radius: radius, angle: CGAngle(beginRadians + char_spacing * Double(index))) + } + } + + private func size(at index: Int) -> CGSize { + sizes[safe: index] ?? CGSize(width: 100, height: 0) + } + + public var body: some View { + GeometryReader { geo in + ZStack { + ForEach(Array(zip(characters.indices, characters)), id: \.0) { index, item in + let polarPt = self.textPoints[index] + let pt = polarPt.cgpoint + let textPt = CGPoint(x: pt.x, y: pt.y) + Sizing { + Text(item).font(font).foregroundColor(textColor).if(showBlueprint) { content in + content.border(Color.blue.opacity(0.5), width:1) + } + }.rotationEffect(polarPt.cgangle.toAngle(offset: CGFloat.pi/2) + Angle.degrees(textUpsideDown ? 180 : 0)).offset(x: textPt.x, y: textPt.y) + } + + }.frame(width: geo.size.width, height: geo.size.height, alignment: .center).onPreferenceChange(ViewSizeKey.self, perform: { value in + sizes = value + }) + } + } +} + +extension RingText { + func setProperty(_ setBlock: (_ text: inout Self) -> Void) -> Self { + let result = _setProperty(content: self) { (tmp :inout Self) in + setBlock(&tmp) + return tmp + } + return result + } + + public func textColor(_ color: Color) -> Self { + setProperty { tmp in + tmp.textColor = color + } + } + + public func kerning(_ pt: Double) -> Self { + let r = acos(1.0 - pow(pt/self.radius, 2)/2.0) + let adjusted = self.char_spacing - kerning_r + r + return setProperty { tmp in + tmp.kerning_r = pt + tmp.char_spacing = adjusted + tmp.textPoints = tmp._createTextPoints() + } + } + + public func begin(degrees: Double) -> Self { + return begin(radians: Double(CGAngle.degrees(degrees))) + } + + public func begin(radians: Double) -> Self { + setProperty { tmp in + let range = tmp.endRadians - tmp.beginRadians + tmp.beginRadians = radians + tmp.endRadians = radians + range + tmp.char_spacing = (tmp.endRadians - tmp.beginRadians)/Double(tmp.characters.count-1) + + tmp.textPoints = tmp._createTextPoints() + } + } + + public func end(degrees: Double) -> Self { + return end(radians: Double(CGAngle.degrees(degrees))) + } + + public func end(radians: Double) -> Self { + setProperty { tmp in + tmp.endRadians = radians + tmp.char_spacing = (tmp.endRadians - tmp.beginRadians)/Double(tmp.characters.count-1) + tmp.textPoints = tmp._createTextPoints() + } + } + + public func upsideDown(_ yes: Bool) -> Self { + setProperty { tmp in + tmp.textUpsideDown = yes + } + } + + public func reverse(_ yes: Bool) -> Self { + setProperty { tmp in + tmp.textReversed = yes + tmp.characters = _createCharacters(origin: tmp.originwords, reversed: tmp.textReversed) + tmp.textPoints = tmp._createTextPoints() + } + } + + public func font(_ f: Font) -> Self { + setProperty { tmp in + tmp.font = f + } + } + + public func showBlueprint(_ show: Bool) -> Self { + setProperty { tmp in + tmp.showBlueprint = show + } + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +struct RingText_Previews: PreviewProvider { + static var previews: some View { + RingTextPreviewWrapper() + } +} + +struct RingTextPreviewWrapper: View { + @State var spacing: Double = 0.0 + @State var begin: Double = 0.0 + @State var end: Double = 360.0 + @State var upside_down: Bool = false + @State var reverse_text: Bool = false + @State var begin_0: Double = -60.0 + @State var font_size: Double = 20.0 + @State var blueprint: Bool = false + var body: some View { + VStack { + HStack { + VStack { + ZStack { + RingText(radius: 40.0, words: ["1","2","3","4","5","6","7","8","9","10","11","12"], color: .blue, upsideDown: false, reversed: false).font(Font.custom("Apple Chancery", size: 16.0)).begin(degrees: begin_0).showBlueprint(blueprint) + RingText(radius: 80.0, text: "0987654321", color: .green).font(.system(size: CGFloat(font_size))).showBlueprint(blueprint) + } + Text("begin degrees: \(begin_0)") + Slider(value: $begin_0, in: 0.0...360) + Text("font size: \(font_size)") + Slider(value: $font_size, in: 10.0...40.0, step: 1) + } + ZStack { + RingText(radius: 40.0, words: ["a23", "b56", "c89"], reversed: true).showBlueprint(blueprint) + RingText(radius: 80.0, words: ["1234567890"]).showBlueprint(blueprint) + } + VStack { + ZStack { + + RingText(radius: 60.0, words: ["12345", "67890"]).begin(degrees: begin) + .end(degrees: end) + .upsideDown(upside_down) + .reverse(reverse_text) + .textColor(.red).showBlueprint(blueprint) + RingText(radius: 40.0, words: ["1234567890"]).kerning(spacing) + .upsideDown(upside_down) + .reverse(reverse_text) + .textColor(.blue).showBlueprint(blueprint) + + } + VStack(alignment: .leading) { + Text("char spacing : \(spacing)") + Slider(value: $spacing, in: 0.0...30.0) + Text("begin degrees: \(begin)") + Slider(value: $begin, in: 0.0...360.0) + Text("end degrees: \(end)") + Slider(value: $end, in: 0.0...360.0) + Toggle("Upside Down", isOn: $upside_down) + Toggle("Reverse Text", isOn: $reverse_text) + } + } + }.background(Color.black) + Toggle("Show Layout", isOn:$blueprint) + } + } +} diff --git a/Sources/Rings/Rings.swift b/Sources/Rings/Rings.swift deleted file mode 100644 index 32fdc9e..0000000 --- a/Sources/Rings/Rings.swift +++ /dev/null @@ -1,3 +0,0 @@ -struct Rings { - var text = "Hello, World!" -} diff --git a/Sources/Rings/Sizing.swift b/Sources/Rings/Sizing.swift new file mode 100644 index 0000000..687c9d8 --- /dev/null +++ b/Sources/Rings/Sizing.swift @@ -0,0 +1,30 @@ +// +// Sizing.swift +// +// Refer to: +// 1. https://swiftui-lab.com/communicating-with-the-view-tree-part-1/ +// 2. https://git.kabellmunk.dk/prototyping-custom-ui-in-swiftui-talk/custom-ui-prototype-in-swiftui/-/blob/master/Custom%20UI%20Prototype/Prototype/CurvedText.swift +// 3. https://www.youtube.com/watch?v=1BHHybRnHFE +// 4. https://www.fivestars.blog/articles/preferencekey-reduce/ +// +// Created by Chen Hai Teng on 4/22/21. +// + +import SwiftUI + +struct ViewSizeKey : PreferenceKey { + static var defaultValue: [CGSize] = [] + static func reduce(value: inout [CGSize], nextValue: () -> [CGSize]) { + value.append(contentsOf: nextValue()) + } + typealias Value = [CGSize] +} + +struct Sizing : View { + var content: ()->V + var body: some View { + content().background(GeometryReader { geo in + Color.clear.preference(key: ViewSizeKey.self, value: [geo.size]) + }) + } +} diff --git a/Tests/RingsTests/RingsTests.swift b/Tests/RingsTests/RingsTests.swift index 86b9b5d..f8214a3 100644 --- a/Tests/RingsTests/RingsTests.swift +++ b/Tests/RingsTests/RingsTests.swift @@ -1,15 +1,17 @@ import XCTest + @testable import Rings +@testable import CoreGraphicsExtension -final class RingsTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(Rings().text, "Hello, World!") +final class RingTextTests: XCTestCase { + func testCGAngleExt() { + let zeroAngle = CGAngle.zero.toAngle() + let rightAngle = CGAngle.degrees(90.0).toAngle() + XCTAssertEqual(zeroAngle.degrees, 0) + XCTAssertEqual(rightAngle.degrees, 90) } static var allTests = [ - ("testExample", testExample), + ("testCGAngleExt", testCGAngleExt), ] }