룰렛 뷰 추가
This commit is contained in:
40
SodaLive/Sources/FortuneWheel/FortuneWheel.swift
Normal file
40
SodaLive/Sources/FortuneWheel/FortuneWheel.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// FortuneWheel.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 2023/12/07.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct FortuneWheel: View {
|
||||
|
||||
private let model: FortuneWheelModel
|
||||
@StateObject private var viewModel: FortuneWheelViewModel
|
||||
|
||||
public init(model: FortuneWheelModel) {
|
||||
self.model = model
|
||||
_viewModel = StateObject(wrappedValue: FortuneWheelViewModel(model: model))
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
ZStack(alignment: .center) {
|
||||
SpinWheelView(data: (0..<model.titles.count).map { _ in Double(100 / model.titles.count) },
|
||||
labels: model.titles, colors: model.colors)
|
||||
.frame(width: model.size, height: model.size)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: model.size / 2)
|
||||
.stroke(lineWidth: model.strokeWidth)
|
||||
.foregroundColor(model.strokeColor)
|
||||
)
|
||||
.rotationEffect(.degrees(viewModel.degree))
|
||||
.onAppear {
|
||||
viewModel.spinWheel()
|
||||
}
|
||||
SpinWheelBolt()
|
||||
}
|
||||
SpinWheelPointer(pointerColor: model.pointerColor).offset(x: 0, y: -25)
|
||||
}
|
||||
}
|
||||
}
|
51
SodaLive/Sources/FortuneWheel/FortuneWheelModel.swift
Normal file
51
SodaLive/Sources/FortuneWheel/FortuneWheelModel.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// FortuneWheelModel.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 2023/12/07.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct FortuneWheelModel {
|
||||
|
||||
let titles: [String]
|
||||
let size: CGFloat
|
||||
let onSpinEnd: ((Int) -> ())?
|
||||
let colors: [Color]
|
||||
let pointerColor: Color
|
||||
let strokeWidth: CGFloat
|
||||
let strokeColor: Color
|
||||
let animDuration: Double
|
||||
let animation: Animation
|
||||
let getWheelItemIndex: (() -> (Int))?
|
||||
|
||||
public init(
|
||||
titles: [String], size: CGFloat, onSpinEnd: ((Int) -> ())?,
|
||||
colors: [Color]? = nil,
|
||||
pointerColor: Color = .red,
|
||||
strokeWidth: CGFloat = 5,
|
||||
strokeColor: Color = .white,
|
||||
animDuration: Double = Double(2),
|
||||
animation: Animation? = nil,
|
||||
getWheelItemIndex: (() -> (Int))? = nil
|
||||
) {
|
||||
self.titles = titles
|
||||
self.size = size
|
||||
self.onSpinEnd = onSpinEnd
|
||||
self.colors = colors ?? [
|
||||
Color(hex: "#F5D55A"),
|
||||
Color(hex: "#E4813B"),
|
||||
Color(hex: "#E6AAC1"),
|
||||
Color(hex: "#8FCEEA"),
|
||||
Color(hex: "#CD5880"),
|
||||
Color(hex: "#C2C85E")
|
||||
]
|
||||
self.pointerColor = pointerColor
|
||||
self.strokeWidth = strokeWidth
|
||||
self.strokeColor = strokeColor
|
||||
self.animDuration = animDuration
|
||||
self.animation = animation ?? Animation.timingCurve(0.51, 0.97, 0.56, 0.99, duration: animDuration)
|
||||
self.getWheelItemIndex = getWheelItemIndex
|
||||
}
|
||||
}
|
59
SodaLive/Sources/FortuneWheel/FortuneWheelViewModel.swift
Normal file
59
SodaLive/Sources/FortuneWheel/FortuneWheelViewModel.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// FortuneWheelViewModel.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 2023/12/07.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
class FortuneWheelViewModel: ObservableObject {
|
||||
|
||||
private var pendingRequestWorkItem: DispatchWorkItem?
|
||||
@Published var degree = 0.0
|
||||
|
||||
private let model: FortuneWheelModel
|
||||
|
||||
init(model: FortuneWheelModel) {
|
||||
self.model = model
|
||||
}
|
||||
|
||||
private func getWheelStopDegree() -> Double {
|
||||
var index = -1;
|
||||
if let method = model.getWheelItemIndex { index = method() }
|
||||
if index < 0 || index >= model.titles.count { index = Int.random(in: 0..<model.titles.count) }
|
||||
index = model.titles.count - index - 1;
|
||||
/*
|
||||
itemRange - Each items degree range (For 4, each will have 360 / 4 = 90 degrees)
|
||||
indexDegree - No. of 90 degrees to reach i item
|
||||
freeRange - Flexible degree in the item, so the pointer doesn't always point start of the item
|
||||
freeSpins - No. of spins before it goes to selected item index
|
||||
finalDegree - Final exact degree to spin and stop in the index
|
||||
*/
|
||||
let itemRange = 360 / model.titles.count;
|
||||
let indexDegree = itemRange * index;
|
||||
let freeRange = Int.random(in: 0...itemRange);
|
||||
let freeSpins = (2...20).map({ return $0 * 360 }).randomElement()!
|
||||
let finalDegree = freeSpins + indexDegree + freeRange;
|
||||
return Double(finalDegree);
|
||||
}
|
||||
|
||||
func spinWheel() {
|
||||
withAnimation(model.animation) {
|
||||
self.degree = Double(360 * Int(self.degree / 360)) + getWheelStopDegree();
|
||||
}
|
||||
// Cancel the currently pending item
|
||||
pendingRequestWorkItem?.cancel()
|
||||
// Wrap our request in a work item
|
||||
let requestWorkItem = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let count = self.model.titles.count
|
||||
let distance = self.degree.truncatingRemainder(dividingBy: 360)
|
||||
let pointer = floor(distance / (360 / Double(count)))
|
||||
if let onSpinEnd = self.model.onSpinEnd { onSpinEnd(count - Int(pointer) - 1) }
|
||||
}
|
||||
// Save the new work item and execute it after duration
|
||||
pendingRequestWorkItem = requestWorkItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + model.animDuration + 1, execute: requestWorkItem)
|
||||
}
|
||||
}
|
38
SodaLive/Sources/FortuneWheel/SpinWheelCell.swift
Normal file
38
SodaLive/Sources/FortuneWheel/SpinWheelCell.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// SpinWheelCell.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 2023/12/07.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SpinWheelCell: Shape {
|
||||
|
||||
let startAngle: Double, endAngle: Double
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let radius = min(rect.width, rect.height) / 2
|
||||
let alpha = CGFloat(startAngle)
|
||||
let center = CGPoint(
|
||||
x: rect.midX,
|
||||
y: rect.midY
|
||||
)
|
||||
path.move(to: center)
|
||||
path.addLine(
|
||||
to: CGPoint(
|
||||
x: center.x + cos(alpha) * radius,
|
||||
y: center.y + sin(alpha) * radius
|
||||
)
|
||||
)
|
||||
path.addArc(
|
||||
center: center, radius: radius,
|
||||
startAngle: Angle(radians: startAngle),
|
||||
endAngle: Angle(radians: endAngle),
|
||||
clockwise: false
|
||||
)
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
96
SodaLive/Sources/FortuneWheel/SpinWheelView.swift
Normal file
96
SodaLive/Sources/FortuneWheel/SpinWheelView.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// SpinWheelView.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 2023/12/07.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct Triangle: Shape {
|
||||
public func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.midX, y: rect.minY))
|
||||
path.addCurve(to: CGPoint(x: rect.midX, y: rect.minY), control1: CGPoint(x: rect.maxX, y: rect.minY), control2: CGPoint(x: rect.midX, y: rect.minY))
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct SpinWheelPointer: View {
|
||||
var pointerColor: Color
|
||||
var body: some View {
|
||||
Triangle().frame(width: 50, height: 50)
|
||||
.foregroundColor(pointerColor).cornerRadius(24)
|
||||
.rotationEffect(.init(degrees: 180))
|
||||
.shadow(color: Color(hex: "212121").opacity(0.5), radius: 5, x: 0.0, y: 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
struct SpinWheelBolt: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle().frame(width: 28, height: 28)
|
||||
.foregroundColor(Color(hex: "F4C25B"))
|
||||
Circle().frame(width: 18, height: 18)
|
||||
.foregroundColor(Color(hex: "FFD25A"))
|
||||
.shadow(color: Color(hex: "404040").opacity(0.35), radius: 3, x: 0.0, y: 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SpinWheelView: View {
|
||||
|
||||
var data: [Double], labels: [String]
|
||||
|
||||
private let colors: [Color]
|
||||
private let sliceOffset: Double = -.pi / 2
|
||||
@available(macOS 10.15, *)
|
||||
|
||||
init(data: [Double], labels: [String], colors: [Color]) {
|
||||
self.data = data
|
||||
self.labels = labels
|
||||
self.colors = colors
|
||||
}
|
||||
@available(macOS 10.15.0, *)
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .center) {
|
||||
ForEach(0..<data.count, id: \.self) { index in
|
||||
SpinWheelCell(startAngle: startAngle(for: index), endAngle: endAngle(for: index))
|
||||
.fill(colors[index % colors.count])
|
||||
Text(labels[index]).font(.custom(Font.medium.rawValue, size: 13)).foregroundColor(Color.black)
|
||||
.offset(viewOffset(for: index, in: geo.size)).zIndex(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startAngle(for index: Int) -> Double {
|
||||
switch index {
|
||||
case 0: return sliceOffset
|
||||
default:
|
||||
let ratio: Double = data[..<index].reduce(0.0, +) / data.reduce(0.0, +)
|
||||
return sliceOffset + 2 * .pi * ratio
|
||||
}
|
||||
}
|
||||
|
||||
private func endAngle(for index: Int) -> Double {
|
||||
switch index {
|
||||
case data.count - 1: return sliceOffset + 2 * .pi
|
||||
default:
|
||||
let ratio: Double = data[..<(index + 1)].reduce(0.0, +) / data.reduce(0.0, +)
|
||||
return sliceOffset + 2 * .pi * ratio
|
||||
}
|
||||
}
|
||||
|
||||
private func viewOffset(for index: Int, in size: CGSize) -> CGSize {
|
||||
let radius = min(size.width, size.height) / 3
|
||||
let dataRatio = (2 * data[..<index].reduce(0, +) + data[index]) / (2 * data.reduce(0, +))
|
||||
let angle = CGFloat(sliceOffset + 2 * .pi * dataRatio)
|
||||
return CGSize(width: radius * cos(angle), height: radius * sin(angle))
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user