iOS. UI. Приëмы. Часть 1

Привет читателям Хабра!

Я iOS-разработчик, и так случилось, что мне приходилось много делать в ui: кастомные view, тени, layout-ы, кнопки и вот это всё. В этой и паре следующих статей хочу поделиться некоторыми приёмами, которые помогали мне добиваться весьма красивых и интересных эффектов в плане рисования компонентов ui. Надеюсь, кому-нибудь это будет полезно. Ну или просто интересно.

Небольшое введение

Не берусь говорить за всех, но, исходя из личного опыта, сложилось впечатление, что для достаточно большого количества разработчиков рисование каких-то «плашек» с нестандартными формой и поведением — крайне нежелательная задача. Кто-то больше в архитектуре, кто-то больше про «сделать бизнесу хорошо» с минимальными усилиями (соответственно, просят поумерить пыл дизайнеров) и т.п. И если уж приходится делать что-то из ряда вон, то начинается google, stackoverflow, эксперименты и т.д., что занимает немало времени, и появляется ощущение, что оно того вообще не стоит. Собственно, эту небольшую серию статей я и задумал как некоторую справку, прочтение которой снимет ряд вопросов и позволит быстрее оценивать/реализовывать нетипичные ui-компоненты. На конкретных примерах постараюсь продемонстрировать, как, что и почему можно делать.

Пример 1: view с нестандартными границей и тенью

9abc17aac29348d6cda15af3f1b6cf0a.png

В данном случае идея простая: добавить ещё один слой в иерархию слоёв нашей view, порезать границы у этого слоя, а форму тени (уже у самой view) сделать ровно такой же, как и форма границы слоя.

Теперь чуть подробнее. У CALayer есть свойство mask.В документации можно прочитать, что это тот же самый опциональный CALayer, и если он не nil, то его альфа-канал используется как маска для контента исходного layer. То есть если взять png-картинку с котом и прозрачностью и каким-то образом засунуть ее в CALayer (назовем его catLayer), то при присваивании layer.mask = catLayer контент нашего исходного layer будет в виде кота, что бы ни находилось у него внутри. Может, текстовый кот получится, если внутри layer много текста. В нашем же случае нужен layer-маска в виде произвольной фигуры. Тут может помочь CAShapeLayer — наследник CALayer, который, грубо говоря, умеет внутри себя рисовать произвольную форму посредством задания ему проперти path. При использовании shapeLayer в качестве маски, всё, что находится вне формы, описываемой shapeLayer.path, работает как фильтр с alpha = 0.

Саму форму можно задать, используя UIBezierPath: для этого у последнего есть функции
addLine (to:), move (to:), addArc (withCenter: radius: startAngle: endAngle: clockwise) и т.д.
Здесь хотелось бы отметить пару моментов. Итоговый path должен выглядеть так, будто его «нарисовали, не отрывая карандаш от бумаги»: стартуем из произвольной точки на границе и постепенно добавляем линии к общему пути так, чтобы конец предыдущей линии был началом следующей линии, и так далее. В конце возвращаемся в исходную точку. Некоторых сбивает с толку функция addArc, потому что в ней есть вроде и startAngle и endAngle, и clockwise. Вот clockwise как раз и нужен для того, чтобы управлять тем, вдоль какой из частей окружности, заданной двумя углами, мы двигаемся. В нашем примере в правом верхнем углу добавляется кусок окружности от -π/2 до 0 с clockwise равным именно true, иначе мы бы просто вырезали целую окружность из нашей view:


А зачем здесь вообще дополнительный слой? Почему бы не задать маску у исходного?
Проблема в том, что маска работает так, что отрезает просто всё, что ей попадётся, в том числе и тень слоя. Так что если задавать mask у слоя исходной view, то тени просто не будет видно.

Наконец, чтобы придать нужную форму тени, у CALayer есть свойство shadowPath.

Полный код примера 1
import UIKit

final class SimpleCustomBorderAndShadowView: UIView {
  private let frontLayer = CALayer()
  private let inset: CGFloat = 40
  
  // MARK: Override
  
  override init(frame: CGRect) {
    super.init(frame: frame)
    setup()
  }
  
  required init?(coder: NSCoder) {
    super.init(coder: coder)
    setup()
  }
  
  override func layoutSubviews() {
    super.layoutSubviews()
    frontLayer.frame = bounds
    
    let maskAndShadowPath = UIBezierPath()
    maskAndShadowPath.move(to: CGPoint(x: 0, y: inset))
    maskAndShadowPath.addLine(to: CGPoint(x: inset, y: 0))
    maskAndShadowPath.addLine(to: CGPoint(x: bounds.width - inset, y: 0))
    maskAndShadowPath.addArc(withCenter: CGPoint(x: bounds.width - inset, y: inset),
                             radius: inset,
                             startAngle: -CGFloat.pi / 2,
                             endAngle: 0,
                             clockwise: true)
    maskAndShadowPath.addLine(to: CGPoint(x: bounds.width, y: bounds.height - inset))
    maskAndShadowPath.addLine(to: CGPoint(x: bounds.width - inset, y: bounds.height))
    maskAndShadowPath.addLine(to: CGPoint(x: inset, y: bounds.height))
    maskAndShadowPath.addArc(withCenter: CGPoint(x: inset, y: bounds.height - inset),
                             radius: inset,
                             startAngle: CGFloat.pi / 2,
                             endAngle: CGFloat.pi,
                             clockwise: true)
    maskAndShadowPath.close()
    
    (frontLayer.mask as? CAShapeLayer)?.frame = bounds
    (frontLayer.mask as? CAShapeLayer)?.path = maskAndShadowPath.cgPath
    layer.shadowPath = maskAndShadowPath.cgPath 
  }
  
  // MARK: Setup
  
  private func setup() {
    backgroundColor = .clear
    
    layer.shadowColor = UIColor.black.cgColor
    layer.shadowOffset = .zero
    layer.shadowRadius = 20
    layer.shadowOpacity = 1
    
    frontLayer.mask = CAShapeLayer()
    frontLayer.backgroundColor = UIColor.white.cgColor
    layer.addSublayer(frontLayer)
  }
}

Пример 2: view с вырезанной кривой произвольного вида

3d6248a226b6a16942a363ad41af4354.png

Данный пример выбран, чтобы продемонстрировать два момента: как вырезать что-то внутри слоя и как создать путь, как бы обводящий кривую линию на некотором расстоянии от неё.

Для того, чтобы вырезать что-то внутри слоя, нужно понимать, по какому правилу происходит раскрашивание форм, созданных с помощью UIBezierPath. В принципе, про это довольно внятно написано здесь. Получается, чтобы добиться эффекта как на картинке выше, нужно в итоговый path для маски добавить путь, обходящий внешнюю границу view, что делается с помощью UIBezierPath (roundedRect: cornerRadius:), и после добавить путь, отвечающей вырезу в форме кривой.

Для формы кривой используется функция addQuadCurve (to: controlPoint:). И если взять UIBezierPath, вызывать addQuadCurve, проставить ему ширину с помощью lineWidth, и добавить это в итоговый path для маски то… Ничего не выйдет. Если чуть-чуть задуматься и ещё вспомнить про это, то всё начинает казаться логичным: CoreGraphics нужно как-то сказать о границах, при переходе через которые происходит подсчёт каких-то counter-ов для дальнейшего решения о том, красить данную область или нет. Чтобы построить путь именно вокруг кривой, у CGPath есть функция copy (strokingWithWidth: lineCap: lineJoin: miterLimit:). СамCGPath, в свою очередь, можно получить из UIBezierPath, обращаясь к свойству cgPath.

Конечно, насчёт кривой именно произвольной формы, описывающей вырезаемую область, я немного слукавил, потому что при возникновении самопересечений с учётом ширины будут возникать проблемы.

Полный код примера 2
import UIKit

final class ErasedPathView: UIView {
  private let frontLayer = CAShapeLayer()
  
  // MARK: Override
  
  override init(frame: CGRect) {
    super.init(frame: frame)
    setup()
  }
  
  required init?(coder: NSCoder) {
    super.init(coder: coder)
    setup()
  }
  
  override func layoutSubviews() {
    super.layoutSubviews()
    
    frontLayer.frame = bounds
    
    let maskAndShadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 20)
    
    let curvePath = UIBezierPath()
    curvePath.move(to: CGPoint(x: bounds.width / 4, y: bounds.height / 4))
    curvePath.addQuadCurve(to: CGPoint(x: bounds.width * 3 / 4, y: bounds.height * 3 / 4),
                           controlPoint: CGPoint(x: bounds.width, y: 0))
    
    let innerPath =  UIBezierPath(cgPath: curvePath.cgPath.copy(strokingWithWidth: 70, lineCap: .round, lineJoin: .round, miterLimit: 0))
    maskAndShadowPath.append(innerPath)
    
    (frontLayer.mask as? CAShapeLayer)?.frame = bounds
    (frontLayer.mask as? CAShapeLayer)?.path = maskAndShadowPath.cgPath
    layer.shadowPath = maskAndShadowPath.cgPath
  }
  
  // MARK: Setup
  
  private func setup() {
    backgroundColor = .clear
    frontLayer.backgroundColor = UIColor.white.cgColor
    
    layer.addSublayer(frontLayer)
    let mask = CAShapeLayer()
    mask.fillRule = .evenOdd
    frontLayer.mask = mask
    
    layer.shadowColor = UIColor.black.cgColor
    layer.shadowOffset = .zero
    layer.shadowRadius = 20
    layer.shadowOpacity = 1
  }
}

Пример 3: рисование форм внутри view

7f30714c8e15b3842dc57968c9205328.png

Для того, чтобы просто рисовать внутри вашей view всё, что нравится, без создания дополнительных слоёв, можно опять же использовать CAShapeLayer. Нужно сделать override статического свойства layerClass у исходной view, возвращая ShapeLayer.self, и так же как и в Примере 1 задать этому слою path.

Есть один нюанс, не упомянутый ранее. При построении непрерывного пути при рисовании произвольной формы можно случайно перепрыгнуть из конца очередной линии в совершенно другое место. Типичный пример — добавление нового куска окружности при непустом path. В таких случаях CoreGraphics просто напросто дорисует за вас недостающую линию, соединяющую последнюю точку пути и новую точку очередной добавляемой линии. В совокупности с fillRule у CAShapeLayer этим можно аккуратно пользоваться. Например, на третьей справа картинке (карта треф) этот подход существенно упрощает рисование: не нужно думать о том, в каких именно местах пересекаются окружности.

Пики
import UIKit

final class SpadeCardView: UIView {
  
  var selfLayer: CAShapeLayer { layer as! CAShapeLayer }
  private let inset: CGFloat = 20
  
  // MARK: Override
  
  static override var layerClass: AnyClass { CAShapeLayer.self }
  
  override init(frame: CGRect) {
    super.init(frame: frame)
    setup()
  }
  
  required init?(coder: NSCoder) {
    super.init(coder: coder)
    setup()
  }
  
  override func layoutSubviews() {
    super.layoutSubviews()
    
    let path = UIBezierPath()
    let size = bounds.width - 2 * inset
    let radius = size / 4
    let alpha = atan(2 * radius / size)
    
    path.move(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2))
    path.addArc(withCenter: CGPoint(x: inset + radius, y: bounds.height / 2),
                radius: radius, startAngle: 0,
                endAngle: CGFloat.pi + 2 * alpha,
                clockwise: true)
    path.addLine(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2 - size / 2))
    path.addArc(withCenter: CGPoint(x: bounds.width / 2 + radius, y: bounds.height / 2),
                radius: radius,
                startAngle: -2 * alpha,
                endAngle: CGFloat.pi,
                clockwise: true)
    path.addQuadCurve(to: CGPoint(x: bounds.width / 2 + radius, y: bounds.height / 2 + size / 2),
                      controlPoint: CGPoint(x: bounds.width / 2, y: bounds.height / 2 + size / 2))
    path.addLine(to: CGPoint(x: bounds.width / 2 - radius, y: bounds.height / 2 + size / 2))
    path.addQuadCurve(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2),
                      controlPoint: CGPoint(x: bounds.width / 2, y: bounds.height / 2 + size / 2))
    
    selfLayer.path = path.cgPath
  }
  
  // MARK: Setup
  
  private func setup() {
    selfLayer.fillColor = UIColor.black.cgColor
    selfLayer.strokeColor = UIColor.black.cgColor
    selfLayer.lineWidth = 2
    
    layer.shadowColor = UIColor.black.cgColor
    layer.shadowOffset = .zero
    layer.shadowRadius = 10
    layer.shadowOpacity = 1
  }
}
Бубны
import UIKit

final class DiamondCardView: UIView {
  
  var selfLayer: CAShapeLayer { layer as! CAShapeLayer }
  private let inset: CGFloat = 20
  private let adjustment: CGFloat = 10
  
  // MARK: Override
  
  static override var layerClass: AnyClass { CAShapeLayer.self }
  
  override init(frame: CGRect) {
    super.init(frame: frame)
    setup()
  }
  
  required init?(coder: NSCoder) {
    super.init(coder: coder)
    setup()
  }
  
  override func layoutSubviews() {
    super.layoutSubviews()
    
    let path = UIBezierPath()
    let size = bounds.width - 2 * inset
    
    path.move(to: CGPoint(x: inset, y: bounds.height / 2))
    path.addQuadCurve(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2 - size / 2),
                      controlPoint: CGPoint(x: bounds.width / 2 - adjustment, y: bounds.height / 2 - adjustment))
    path.addQuadCurve(to: CGPoint(x: bounds.width - inset, y: bounds.height / 2),
                      controlPoint: CGPoint(x: bounds.width / 2 + adjustment, y: bounds.height / 2 - adjustment))
    path.addQuadCurve(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2 + size / 2),
                      controlPoint: CGPoint(x: bounds.width / 2 + adjustment, y: bounds.height / 2 + adjustment))
    path.addQuadCurve(to: CGPoint(x: inset, y: bounds.height / 2),
                      controlPoint: CGPoint(x: bounds.width / 2 - adjustment, y: bounds.height / 2 + adjustment))
    
    selfLayer.path = path.cgPath
  }
  
  // MARK: Setup
  
  private func setup() {
    selfLayer.fillColor = UIColor.red.cgColor
    selfLayer.strokeColor = UIColor.red.cgColor
    selfLayer.lineWidth = 2
    
    layer.shadowColor = UIColor.black.cgColor
    layer.shadowOffset = .zero
    layer.shadowRadius = 20
    layer.shadowOpacity = 1
  }
}
Трефы
import UIKit

final class ClubCardView: UIView {
  
  var selfLayer: CAShapeLayer { layer as! CAShapeLayer }
  private let inset: CGFloat = 20
  private let adjustment: CGFloat = 10
  
  // MARK: Override
  
  static override var layerClass: AnyClass { CAShapeLayer.self }
  
  override init(frame: CGRect) {
    super.init(frame: frame)
    setup()
  }
  
  required init?(coder: NSCoder) {
    super.init(coder: coder)
    setup()
  }
  
  override func layoutSubviews() {
    super.layoutSubviews()
    
    let path = UIBezierPath()
    let size = bounds.width - 2 * inset
    let radius = size / 4
    
    path.move(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2))
    path.addArc(withCenter: CGPoint(x: bounds.width / 2 - radius, y: bounds.height / 2 + adjustment),
                radius: radius,
                startAngle: 0,
                endAngle: 2 * CGFloat.pi,
                clockwise: true)
    path.addArc(withCenter: CGPoint(x: bounds.width / 2, y: bounds.height / 2 - radius),
                radius: radius,
                startAngle: CGFloat.pi / 2,
                endAngle: 5 * CGFloat.pi / 2,
                clockwise: true)
    path.addArc(withCenter: CGPoint(x: bounds.width / 2 + radius, y: bounds.height / 2 + adjustment),
                radius: radius,
                startAngle: CGFloat.pi,
                endAngle: 3 * CGFloat.pi,
                clockwise: true)
    path.addQuadCurve(to: CGPoint(x: bounds.width / 2 + radius, y: bounds.height / 2 + size / 2),
                      controlPoint: CGPoint(x: bounds.width / 2, y: bounds.height / 2 + size / 2))
    path.addLine(to: CGPoint(x: bounds.width / 2 - radius, y: bounds.height / 2 + size / 2))
    path.addQuadCurve(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2),
                      controlPoint: CGPoint(x: bounds.width / 2, y: bounds.height / 2 + size / 2))
    
    selfLayer.path = path.cgPath
  }
  
  // MARK: Setup
  
  private func setup() {
    selfLayer.fillColor = UIColor.black.cgColor
    selfLayer.strokeColor = UIColor.black.cgColor
    selfLayer.fillRule = .nonZero
    selfLayer.lineWidth = 2
    
    layer.shadowColor = UIColor.black.cgColor
    layer.shadowOffset = .zero
    layer.shadowRadius = 20
    layer.shadowOpacity = 1
  }
}
Черви
import UIKit

final class HeartCardView: UIView {
  
  var selfLayer: CAShapeLayer { layer as! CAShapeLayer }
  private let inset: CGFloat = 20
  
  // MARK: Override
  
  static override var layerClass: AnyClass { CAShapeLayer.self }
  
  override init(frame: CGRect) {
    super.init(frame: frame)
    setup()
  }
  
  required init?(coder: NSCoder) {
    super.init(coder: coder)
    setup()
  }
  
  override func layoutSubviews() {
    super.layoutSubviews()
    
    let path = UIBezierPath()
    let size = bounds.width - 2 * inset
    let radius = size / 4
    let alpha = atan(4 * radius / (3 * size))
    
    path.move(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2 + size / 2))
    path.addArc(withCenter: CGPoint(x: inset + radius, y: bounds.height / 2 - radius),
                radius: radius,
                startAngle: CGFloat.pi - 2 * alpha,
                endAngle: 0,
                clockwise: true)
    path.addArc(withCenter: CGPoint(x: bounds.width / 2 + radius, y: bounds.height / 2 - radius),
                radius: radius,
                startAngle: -CGFloat.pi,
                endAngle: 2 * alpha,
                clockwise: true)
    path.addLine(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2 + size / 2))
    
    selfLayer.path = path.cgPath
  }
  
  // MARK: Setup
  
  private func setup() {
    selfLayer.fillColor = UIColor.red.cgColor
    selfLayer.strokeColor = UIColor.red.cgColor
    
    layer.shadowColor = UIColor.black.cgColor
    layer.shadowOffset = .zero
    layer.shadowRadius = 20
    layer.shadowOpacity = 1
  }
}

Заключение

Ниже, так сказать, things to remember:

  • +1 CALayer, mask, CAShapeLayer, shadowPath — для кастомной границы и тени

  • copy (strokingWithWidth: lineCap: lineJoin: miterLimit:) –для объемной обводки path

  • CAShapeLayer, path + fillRule –даёт интересные возможности

В следующей статье на эту тему постараюсь рассказать про layout-ы в коллекциях. Всем добра, пишите классные приложения и делайте красоту в интерфейсах!

© Habrahabr.ru