Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Лотерея в приложении: отрисовка, анимация и никакого мошенничества - Наталья Никитина, Revolut

5d08ba0cd07942f2ddbf82e5b21ba5e7?s=47 FunCorp
February 15, 2020

Лотерея в приложении: отрисовка, анимация и никакого мошенничества - Наталья Никитина, Revolut

5d08ba0cd07942f2ddbf82e5b21ba5e7?s=128

FunCorp

February 15, 2020
Tweet

Transcript

  1. Лотерея
 в приложении Отрисовка, анимация и никакого мошенничества

  2. 2 Наталья Никитина iOS Developer
 ~3 года в iOS-разработке

  3. 3 Revolut Компания основана в 2015 г. 10+ млн пользователей

    20+ офисов в мире 2000+ сотрудников Оценка компании $1,7 млрд
  4. •Цель: привлекать и удерживать пользователей в приложении Revolut •Моя задача:

    разработка реферальной программы 4 Команда: 
 Growth & Engagement
  5. 5 Что из себя представляет лотерея
 в приложении, зачем она

    нужна Как реализовывали: рисовали, 
 анимировали
 и обрабатывали взаимодействие
 с колесом Где могут пригодиться описанные технологии и возможности О чём расскажу
  6. Как появилась задача «сделать лотерею
 в приложении»

  7. •Пользователь может выиграть какую-то сумму •Может крутить колесо пальцем, свайпать,

    запустить по нажатию на кнопку •Лотерея — часть одной из реферальных кампаний Как выглядит лотерея 7
  8. 8 Первые идеи реализации •Использовать готовую анимацию. Например, Lottie ❌

    Нет возможности задавать разное количество слотов на колесе ❌ Нельзя задать разные значения на слотах, менять скорость, пользователь не сможет взаимодействовать •Использовать готовую картинку для отрисовки
  9. 9 Нарисовать колесо Реализовать взаимодействие с ним — обработку жестов

    Добавить нужные анимации 1⃣ 2⃣ 3⃣ Реализация
  10. 10 О чем расскажу •Как рисовать кастомные элементы в iOS

    с помощью геометрических фигур, кривых, масок (Core Graphics, Core Animation, UIKit) •Как реализовать взаимодействие с элементом с помощью UIGestureRecognizer и CGAffineTransform (изменение угла поворота, положения, размера) •Как создавать анимации разными способами (UIView.animate, CABasicAnimation, CAMediaTimingFunction, CADisplayLink)
  11. Отрисовка колеса

  12. 12 Что нужно было сделать •Добавить треугольник с лейблом •Добавить

    все треугольники по кругу •Вырезать круг, чтобы убрать края •Добавить дуги •Добавить точки ❗Использовала Core Graphics
  13. 13 Что нужно было сделать •Добавить треугольник с лейблом •Добавить

    все треугольники по кругу •Вырезать круг, чтобы убрать края •Добавить дуги •Добавить точки
  14. Впишем треугольник в UIView с заданным размером Знаем: количество слотов

    и размер колеса Найдём: высоту UIView tg(A) = a / b a = b * tg(A) let angle = 360.0 / slots.count / 2 let height = width * tan(angle * .pi / 180) * 2 Рисуем треугольник 14 ➡
  15. 15 Код метода drawRect: override public func draw(_ rect: CGRect)

    { guard let context = UIGraphicsGetCurrentContext() else { return } let points = [ CGPoint(x: 0, y: model.height / 2), CGPoint(x: model.width, y: 0), CGPoint(x: model.width, y: model.height) ] context.beginPath() context.addLines(between: points) context.closePath() let shapeLayer = CAShapeLayer() shapeLayer.path = context.path layer.mask = shapeLayer }
  16. 16 Код метода drawRect: override public func draw(_ rect: CGRect)

    { guard let context = UIGraphicsGetCurrentContext() else { return } let points = [ CGPoint(x: 0, y: model.height / 2), CGPoint(x: model.width, y: 0), CGPoint(x: model.width, y: model.height) ] context.beginPath() context.addLines(between: points) context.closePath() let shapeLayer = CAShapeLayer() shapeLayer.path = context.path layer.mask = shapeLayer }
  17. 17 Код метода drawRect: override public func draw(_ rect: CGRect)

    { guard let context = UIGraphicsGetCurrentContext() else { return } let points = [ CGPoint(x: 0, y: model.height / 2), CGPoint(x: model.width, y: 0), CGPoint(x: model.width, y: model.height) ] context.beginPath() context.addLines(between: points) context.closePath() let shapeLayer = CAShapeLayer() shapeLayer.path = context.path layer.mask = shapeLayer }
  18. 18 Код метода drawRect: override public func draw(_ rect: CGRect)

    { guard let context = UIGraphicsGetCurrentContext() else { return } let points = [ CGPoint(x: 0, y: model.height / 2), CGPoint(x: model.width, y: 0), CGPoint(x: model.width, y: model.height) ] context.beginPath() context.addLines(between: points) context.closePath() let shapeLayer = CAShapeLayer() shapeLayer.path = context.path layer.mask = shapeLayer }
  19. 19 Что нужно было сделать •Добавить треугольник с лейблом •Добавить

    все треугольники по кругу •Вырезать круг, чтобы убрать края •Добавить дуги •Добавить точки
  20. 20 Упрощённый код func addSlice(width: CGFloat, angle: CGFloat, index: Int,

    text: String) { let model = SliceViewModel(width: width, angle: angle, text: text) let slice = SliceView(model: model) view.addSubview(slice) < … setup constraints, width & height of a slice … > slice.layer.anchorPoint = CGPoint(x: 0, y: 0.5) slice.transform = slice.transform.rotated( by: index * angle * .pi / 180 ) return model }
  21. func addSlice(width: CGFloat, angle: CGFloat, index: Int, text: String) {

    let model = SliceViewModel(width: width, angle: angle, text: text) let slice = SliceView(model: model) view.addSubview(slice) < … setup constraints, width & height of a slice … > slice.layer.anchorPoint = CGPoint(x: 0, y: 0.5) slice.transform = slice.transform.rotated( by: index * angle * .pi / 180 ) return model } Упрощённый код 21
  22. 22 Что нужно было сделать •Добавить треугольник с лейблом •Добавить

    все треугольники по кругу •Вырезать круг, чтобы убрать края •Добавить дуги •Добавить точки
  23. 23 Код let rect = CGRect( origin: .zero, size: CGSize(width:

    wheelSize, height: wheelSize) ) let path = UIBezierPath(roundedRect: rect, cornerRadius: wheelSize / 2) let shapeLayer = CAShapeLayer() shapeLayer.path = path.cgPath view.layer.mask = shapeLayer
  24. 24 Что нужно было сделать •Добавить треугольник с лейблом •Добавить

    все треугольники по кругу •Вырезать круг, чтобы убрать края •Добавить дуги •Добавить точки
  25. 25 Код let path = UIBezierPath( arcCenter: CGPoint(x: wheelSize /

    2, y: wheelSize / 2), radius: wheelSize / 2, startAngle: (index * angle - angle / 2) * .pi / 180, endAngle: (index * angle + angle / 2) * .pi / 180, clockwise: true ) let shapeLayer = CAShapeLayer() shapeLayer.path = path.cgPath shapeLayer.strokeColor = index % 2 == 0 ? .mainPink : .mainBlue shapeLayer.fillColor = UIColor.clear.cgColor shapeLayer.lineWidth = borderWidth layer.addSublayer(shapeLayer)
  26. 26 Что нужно было сделать •Добавить треугольник с лейблом •Добавить

    все треугольники по кругу •Вырезать круг, чтобы убрать края •Добавить дуги •Добавить точки path.currentPoint
  27. CAReplicatorLayer 27 •Позволяет быстро и эффективно создать коллекцию схожих layer-ов

    •К каждой копии применяется заданный transform и изменение некоторых свойств 27
  28. CAReplicatorLayer 28 let replicatorLayer = CAReplicatorLayer() let square = CALayer()

    square.backgroundColor = UIColor.white.cgColor square.frame = CGRect(x: 0, y: 0, width: 100, height: 100) let instanceCount = 5 replicatorLayer.instanceCount = instanceCount replicatorLayer.instanceTransform = CATransform3DMakeTranslation(110, 0, 0) let offsetStep = -1 / Float(instanceCount) replicatorLayer.instanceBlueOffset = offsetStep replicatorLayer.instanceGreenOffset = offsetStep replicatorLayer.addSublayer(square) 28
  29. CAReplicatorLayer - конфигурация 29 •instanceCount •instanceDelay •instanceTransform •RGB channels, alpha

    change 29
  30. Взаимодействие с колесом

  31. •Позволяет реагировать на непрерывное движение по экрану •Можно определить текущую

    позицию и скорость жеста: location(in:) + velocity(in:) •Нужно следить за изменением угла, который проходит через текущую точку под пальцем пользователя 31 UIPanGestureRecognizer
  32. •tg(A) = x / y •arctg(tg(x / y)) = A

    32 Как посчитать текущий угол let currentPoint = pan.location(in: view) let x = currentPoint.x - wheelCenter.x let y = currentPoint.y - wheelCenter.y let angle = 180 - atan2(x, y) * 180 / .pi let diff = angle - previousAngle view.transform = view.transform.rotated(by: diff)
  33. Анимации

  34. 34 Механика •Заранее знаем, какую сумму выиграет пользователь •Быстрый жест

    — колесо крутится до выигрышного слота •Медленный жест — крутится до момента естественной остановки, затем возвращается назад •Если пользователь крутил колесо и потом отпустил — возвращается назад
  35. 35 Какие анимации надо было сделать •Прокрутка до выигрышного слота:

    быстрый жест •Прокрутка до момента естественной остановки: медленный жест •Прокрутка назад до исходного положения: завершение медленного жеста + в случае ошибки и т. п. •Анимация пина
  36. Прокрутка до выигрышного слота У жеста есть скорость. Надо знать:

    •за какое время с учетом начальной скорости должно остановиться колесо •на сколько градусов должно прокрутиться колесо за это время 36
  37. 37 Упрощённый код let angle = winSliceAngle(index: index) let rotationKeyPath

    = "transform.rotation.z" let fromValue = view.layer.value(forKeyPath: rotationKeyPath) let toValue = isRotatingClockwise ? angle + .pi * 8 : angle - .pi * 8 view.layer.setValue(toValue, forKeyPath: view.rotationKeyPath) let animation = CABasicAnimation() animation.keyPath = rotationKeyPath animation.duration = 3 animation.fromValue = fromValue animation.toValue = toValue animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.16, 0.46, 0.33, 1) view.layer.add(animation, forKey: nil)
  38. 38 Упрощённый код let angle = winSliceAngle(index: index) let rotationKeyPath

    = "transform.rotation.z" let fromValue = view.layer.value(forKeyPath: rotationKeyPath) let toValue = isRotatingClockwise ? angle + .pi * 8 : angle - .pi * 8 view.layer.setValue(toValue, forKeyPath: view.rotationKeyPath) let animation = CABasicAnimation() animation.keyPath = rotationKeyPath animation.duration = 3 animation.fromValue = fromValue animation.toValue = toValue animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.16, 0.46, 0.33, 1) view.layer.add(animation, forKey: nil)
  39. 39 Находим угол выигрышного слота let sliceAngle = wheelModel.sliceAngle *

    CGFloat(index) let angle = (isRotatingClockwise ? 360 - rotationAngle : -rotationAngle) * .pi / 180
  40. 40 Упрощённый код let angle = winSliceAngle(index: index) let rotationKeyPath

    = "transform.rotation.z" let fromValue = view.layer.value(forKeyPath: rotationKeyPath) let toValue = isRotatingClockwise ? angle + .pi * 8 : angle - .pi * 8 view.layer.setValue(toValue, forKeyPath: view.rotationKeyPath) let animation = CABasicAnimation() animation.keyPath = rotationKeyPath animation.duration = 3 animation.fromValue = fromValue animation.toValue = toValue animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.16, 0.46, 0.33, 1) view.layer.add(animation, forKey: nil)
  41. 41 CABasicAnimation & CAMediaTimingFunction •animate(withDuration:delay:options:animations:completion:) •CABasicAnimation - an object that

    provides basic, single-keyframe animation capabilities for a layer property •Timing functions: .easeIn, .easeOut и т.д. + кастомные
  42. 42 •init(name:) — стандартные timing-функции •init(controlPoints:_:_:_:) — кастомные с помощью

    кубической кривой Безье График отображает, насколько быстро в зависимости
 от времени (ось X) меняется значение, которое вы анимируете (ось Y) CAMediaTimingFunction
  43. 43 .easeOut и кастомная функции (0,0; 0.58,1) (0.16, 0.46; 0.33,

    1)
  44. 44

  45. 45 Упрощённый код let angle = winSliceAngle(index: index) let rotationKeyPath

    = "transform.rotation.z" let fromValue = view.layer.value(forKeyPath: rotationKeyPath) let toValue = isRotatingClockwise ? angle + .pi * 8 : angle - .pi * 8 view.layer.setValue(toValue, forKeyPath: view.rotationKeyPath) let animation = CABasicAnimation() animation.keyPath = rotationKeyPath animation.duration = 3 animation.fromValue = fromValue animation.toValue = toValue animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.16, 0.46, 0.33, 1) view.layer.add(animation, forKey: nil)
  46. 46 Virtual Properties let angle = winSliceAngle(index: index) let rotationKeyPath

    = "transform.rotation.z" let fromValue = view.layer.value(forKeyPath: rotationKeyPath) let toValue = isRotatingClockwise ? angle + .pi * 8 : angle - .pi * 8 view.layer.setValue(toValue, forKeyPath: view.rotationKeyPath) let animation = CABasicAnimation() animation.keyPath = rotationKeyPath animation.duration = 3 animation.fromValue = fromValue animation.toValue = toValue animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.16, 0.46, 0.33, 1) view.layer.add(animation, forKey: nil)
  47. 47 Presentation & model of a layer let angle =

    winSliceAngle(index: index) let rotationKeyPath = "transform.rotation.z" let fromValue = view.layer.value(forKeyPath: rotationKeyPath) let toValue = isRotatingClockwise ? angle + .pi * 8 : angle - .pi * 8 view.layer.setValue(toValue, forKeyPath: view.rotationKeyPath) let animation = CABasicAnimation() animation.keyPath = rotationKeyPath animation.duration = 3 animation.fromValue = fromValue animation.toValue = toValue animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.16, 0.46, 0.33, 1) view.layer.add(animation, forKey: nil)
  48. Прокрутка до момента естественной остановки •те же CABasicAnimation и timing-функция

    •продолжительность — 1 секунда •угол поворота — velocity / 5 48
  49. Прокрутка назад до исходного положения UIView.animate( withDuration: 1, delay: 0,

    usingSpringWithDamping: 0.7, initialSpringVelocity: 1, options: [.curveEaseOut], animations: { view.transform = CGAffineTransform( rotationAngle: angle ) }, completion: completion ) damping ratio — от 1 (без колебаний) до 0 (сильные колебания) 49
  50. 50 Анимация пина •Нужно знать, когда пин проходит границу секции

    •Это можно понять по значению текущего угла поворота •А как узнать текущий угол? 50
  51. 51 CADisplayLink let displayLink = CADisplayLink(target: self, selector:#selector(handle)) displayLink.add(to: .main,

    forMode: .default) deinit { displayLink.invalidate() } •Позволяет синхронизировать что-либо с refresh rate экрана
  52. 52 Handle screen update let rotationKeyPath = “transform.rotation.z" let presentation

    = wheelView.layer.presentation() let angle = presentation.value(forKeyPath: rotationKeyPath) let pinAngle = < … pin angle calculation … > pinView.transform = CGAffineTransform(rotationAngle: pinAngle)
  53. Вот и всё! :)

  54. 54 Выводы •Рисовать кастомные элементы в iOS можно с помощью

    геометрических фигур, кривых, масок (Core Graphics, CAShapeLayer, UIBezierPath). •Для изменения угла поворота вьюхи, положения, скейлинга можем использовать свойство transform (CGAffineTransform). •Анимации можно реализовать разными способами, и всё зависит от потребностей (UIView.animate, CABasicAnimation, CAMediaTimingFunction, CADisplayLink).
  55. 55 Выводы •Кастомный UI-элемент не возьмешь из коробки, но создать

    его самому не так сложно. •Большую непонятную задачу можно разделить на маленькие. Тогда реализовать её будет проще. •Лучшее решение может оказаться простым, если взглянуть на задачу шире. •Кастомные элементы и анимации — это весело. И ещё — полезно для бизнеса. :)
  56. Наталья Никитина iOS Developer @ Revolut natalia.nikitina@revolut.com @agerstown