728x90
Fortune wheel in Swift
A while ago i got a requirement to develop a spin wheel or fortune wheel (like the one in wheel of fortune TV show). The only difference is…
medium.com
기본적인 원리는 위의 블로그를 참고해서 만들었다. 디테일은 내가 임의로 수정하고 추가했다.
Github: https://github.com/Sky-Titan/RouletteExample
GitHub - Sky-Titan/RouletteExample
Contribute to Sky-Titan/RouletteExample development by creating an account on GitHub.
github.com
구성
제대로 그린게 맞는진 모르겠지만 대충 이런 구성으로 되어있다.
- SpinWheelViewDelegate: 외부와 SpinWheelView와의 인터페이스 담당
- SpinWheelView: roulette 커스텀 뷰
- SpinWheelLayer: SpinWheelView의 main CALayer
- SliceLayer: 룰렛의 조각들을 담당하는 SpinWheelLayer의 subLayer들
- SpinWheelItemModel: SliceLayer 하나에 대응되는 roulette 렌더링에 필요한 DTO
SpinWheelView
class SpinWheelView: UIView {
override class var layerClass: AnyClass {
return SpinWheelLayer.self
}
weak var delegate: SpinWheelViewDelegate?
var spinWheelLayer: SpinWheelLayer? {
return layer as? SpinWheelLayer
}
var ringImage: UIImage? {
get {
spinWheelLayer?.ringImage
}
set {
spinWheelLayer?.ringImage = newValue
spinWheelLayer?.setNeedsDisplay()
}
}
var ringLineWidth: CGFloat {
get {
spinWheelLayer?.ringLineWidth ?? 0
}
set {
spinWheelLayer?.ringLineWidth = newValue
spinWheelLayer?.setNeedsDisplay()
}
}
var items: [SpinWheelItemModel] {
get {
spinWheelLayer?.items ?? []
}
set {
spinWheelLayer?.items = newValue
spinWheelLayer?.setNeedsDisplay()
}
}
private var willEndIndex: Int?
func spinWheel(_ index: Int) {
guard !items.isEmpty else { return }
delegate?.spinWheelWillStart(self)
willEndIndex = index
spinWheelLayer?.add(spinAnimation(endIndex: index), forKey: "spin")
}
private func spinAnimation(endIndex: Int) -> CAAnimationGroup {
let group = CAAnimationGroup()
let begin = beginAnimation()
let turn = turnAnimation(begin: begin.duration)
let degressOfSlice: Degree = 360 / CGFloat(items.count)
let beginAngle: Degree = 0
let endAngle: Degree = (beginAngle + degressOfSlice * CGFloat(items.count - endIndex))
let end = endAnimation(begin: begin.duration + turn.duration, endAngle: endAngle)
group.animations = [begin, turn, end]
group.beginTime = CACurrentMediaTime()
group.duration = (begin.duration) + (turn.duration) + (end.duration)
group.delegate = self
group.isRemovedOnCompletion = false
group.fillMode = .forwards
return group
}
private func beginAnimation() -> CABasicAnimation {
let animation = CABasicAnimation(keyPath: "transform.rotation")
animation.fromValue = 0
animation.toValue = Degree(360).toRadian()
animation.duration = 1.5
animation.beginTime = 0
animation.timingFunction = CAMediaTimingFunction(name: .easeIn)
return animation
}
private func turnAnimation(begin: Double) -> CABasicAnimation {
let animation = CABasicAnimation(keyPath: "transform.rotation")
animation.fromValue = 0
animation.toValue = Degree(720).toRadian()
animation.duration = 2
animation.timingFunction = CAMediaTimingFunction(name: .linear)
animation.beginTime = begin
return animation
}
private func endAnimation(begin: Double, endAngle: Degree) -> CABasicAnimation {
let animation = CABasicAnimation(keyPath: "transform.rotation")
animation.fromValue = 0
animation.toValue = endAngle.toRadian()
animation.duration = 1.2
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
animation.isRemovedOnCompletion = false
animation.beginTime = begin
animation.fillMode = .forwards
return animation
}
}
extension SpinWheelView: CAAnimationDelegate {
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if flag, let index = willEndIndex, items.count > index {
delegate?.spinWheelDidEnd(self, at: items[index])
}
}
}
- ringImage: 룰렛의 ring에 해당하는 image
- ringLineWidth: ring의 width값. 해당 값으로 룰렛 원판의 width를 조절해야지 ring image가 잘리는 현상이 없음
- items: Slice들을 그리는데 필요한 DTO 배열
- spinWheel(_ index:) 메서드를 통해 룰렛을 시작할 수 있다.
- spinAnimation(endIndex: Int): 룰렛이 회전하여 endIndex에 해당하는 sliceLayer에 위치하도록 하는 애니메이션을 반환한다.
- beginAnimation(): 룰렛 전체 애니메이션 중 easeIn 타이밍으로 도입부 애니메이션을 반환한다.
- turnAnimation(): 룰렛 전체 애니메이션 중 linear 타이밍으로 중간부분 애니메이션을 반환한다.
- endAnimation(): 룰렛 전체 애니메이션 중 easeOut 타이밍으로 마지막 endIndex까지 도달하는 애니메이션을 반환한다.
SpinWheelLayer
class SpinWheelLayer: CALayer {
fileprivate(set) var items: [SpinWheelItemModel] = []
fileprivate(set) var ringImage: UIImage?
fileprivate(set) var ringLineWidth: CGFloat = 0
override func draw(in ctx: CGContext) {
initializeLayer()
setMask()
setSlicesIfNeeded()
setRingImageIfNeeded()
}
private func initializeLayer() {
self.contentsScale = UIScreen.main.scale
removeAllAnimations()
self.sublayers?.forEach({
$0.removeFromSuperlayer()
})
}
private func setMask() {
let maskLayer = CAShapeLayer()
maskLayer.path = UIBezierPath(arcCenter: CGPoint(x: bounds.width / 2, y: bounds.height / 2), radius: min(bounds.width / 2, bounds.height / 2), startAngle: 0, endAngle: .pi * 2, clockwise: false).cgPath
mask = maskLayer
}
private func setSlicesIfNeeded() {
guard !items.isEmpty else { return }
let degreeOfSlice: Degree = 360 / CGFloat(items.count)
let beginAngle: Degree = (-90) - (degreeOfSlice / 2)
var startAngle: Degree = beginAngle
var endAngle: Degree = startAngle + degreeOfSlice
for index in 0 ..< items.count {
let slice = SliceLayer(model: items[index], index: index, frame: bounds, radius: min(bounds.width / 2, bounds.height / 2), startAngle: startAngle, endAngle: endAngle, totalCount: items.count)
startAngle += degreeOfSlice
endAngle += degreeOfSlice
self.addSublayer(slice)
slice.position = CGPoint(x: bounds.width / 2, y: bounds.height / 2)
slice.setNeedsDisplay()
}
}
private func setRingImageIfNeeded() {
guard let ringImage = ringImage else {
return
}
let ringImageLayer = CALayer()
ringImageLayer.frame = CGRect(x: 0, y: 0, width: self.bounds.width - ringLineWidth, height: self.bounds.height - ringLineWidth)
ringImageLayer.contentsScale = self.contentsScale
ringImageLayer.contents = ringImage.cgImage
ringImageLayer.contentsGravity = .center
self.addSublayer(ringImageLayer)
ringImageLayer.position = CGPoint(x: bounds.width / 2, y: bounds.height / 2)
}
}
- initializeLayer(): layer에 포함되어있던 애니메이션과 sublayer를 모두 제거하여 초기화하는 작업을 한다.
- setMask(): layer에 원형의 마스크를 씌운다.
- setSlicesIfNeeded(): items 배열에 들어있는 수만큼 SliceLayer들을 추가해 룰렛을 그린다.
- setRingImageIfNeeded(): 룰렛의 ring에 해당하는 image를 추가한다.
SliceLayer
class SliceLayer: CALayer {
let model: SpinWheelItemModel
let startAngle: Degree
let endAngle: Degree
let radius: CGFloat
let index: Int
let totalCount: Int
init(model: SpinWheelItemModel, index: Int, frame: CGRect, radius: CGFloat, startAngle: Degree, endAngle: Degree, totalCount: Int) {
self.model = model
self.index = index
self.startAngle = startAngle
self.endAngle = endAngle
self.radius = radius
self.totalCount = totalCount
super.init()
self.frame = frame
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(in ctx: CGContext) {
self.contentsScale = UIScreen.main.scale
drawSlice(in: ctx)
addTextLayer()
}
private func drawSlice(in ctx: CGContext) {
let center: CGPoint = self.position
ctx.move(to: center)
ctx.addArc(center: center, radius: radius, startAngle: startAngle.toRadian(), endAngle: endAngle.toRadian(), clockwise: false)
ctx.setFillColor(model.backgroundColor?.cgColor ?? UIColor.white.cgColor)
ctx.fillPath()
}
private func addTextLayer() {
let center: CGPoint = self.position
let textLayer = CATextLayer()
textLayer.frame = CGRect(x: center.x, y: 0, width: frame.width / 2, height: 15)
textLayer.anchorPoint = CGPoint(x: 0, y: 0.5)
textLayer.position = CGPoint(x: center.x, y: center.y)
textLayer.foregroundColor = UIColor.white.cgColor
textLayer.string = model.text
textLayer.fontSize = 15
textLayer.alignmentMode = .center
addSublayer(textLayer)
let degreeOfSlice: Degree = 360 / CGFloat(totalCount)
textLayer.transform = CATransform3DMakeAffineTransform(CGAffineTransform(rotationAngle: (endAngle - degreeOfSlice / 2).toRadian()))
}
}
- drawSlice(in:): CGContext를 이용해 Slice모양을 그린다.
- addTextLayer(): 각 slice가 보여주어야할 text를 표현하는 subLayer를 추가한다.
SpinWheelItemModel
struct SpinWheelItemModel {
let text: String
let backgroundColor: UIColor?
let value: Int
}
- text: slice별로 보여줄 텍스트
- backgroundColor: slice의 배경색
- value: slice별로 대응하는 값
- EX) roulette이 결정하는 것이 '당첨금'이라고 치면 1000원, 2000원 등에 해당하는 값들
728x90
'iOS > 예제' 카테고리의 다른 글
[iOS 예제] UICollectionView에서 cell 수직방향 정렬 (0) | 2022.08.06 |
---|---|
[iOS 예제] Recording Wave View 만들기 (0) | 2022.07.02 |
[iOS 예제] Ripple Effect (0) | 2022.06.19 |
[iOS 예제] CircleProgressView 만들기 (0) | 2021.12.11 |
[iOS 예제] UIView에 원형으로 shadow 넣기 (0) | 2021.12.07 |
댓글