728x90
기본적인 원리는 위의 블로그를 참고해서 만들었다. 디테일은 내가 임의로 수정하고 추가했다.
Github: https://github.com/Sky-Titan/RouletteExample
구성
제대로 그린게 맞는진 모르겠지만 대충 이런 구성으로 되어있다.
- 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 |
댓글