Введение

Все мы знаем как выглядит стандартный индикатор загрузки (далее - спиннер или лоадер) на наших iOS устройствах, который отображается при загрузке данных и других кейсах, когда пользователь вынужден ждать окончание какого-либо процесса. Но мне всегда было интересно сделать свой кастомный лоадер, чтобы применять его в своих проектах, поэтому я и решил изучить эту сторону вопроса. Конечно, можно использовать успешно и стандартный лоадер и популярные библиотеки (например PKHUD), но давайте попробуем сегодня весело покастомизировать!

Тестировалось на xcode 13.2.1, swift 5

Стандартный iOS UIActivityIndicator
Стандартный iOS UIActivityIndicator

1. Создаем свой простенький спиннер

Мы создадим свой класс, который будет использовать элементы фреймворка СoreAnimations - специального фреймворка для отображения анимаций и не только. Из официальной документации:

СoreAnimations обеспечивает высокую частоту кадров и плавную анимацию, не нагружая ЦП и не замедляя работу приложения. Большая часть работы, необходимая для отрисовки каждого кадра анимации, выполняется за вас. Вы настраиваете параметры анимации, такие как начальная и конечная точки, а Core Animation делает все остальное, перекладывая большую часть работы на специальное графическое оборудование для ускорения рендеринга.

Основными элементами, которые мы будем использовать, будут два класса CoreAnimations:

1) CAShapeLayer - это слой, который может отображать кривые Безье UIBezierPath

2) CAReplicatorLayer - это слой, с помощью которого можно создать копии определенного подслоя с различными геометрическими, временными и цветовыми преобразованиями.

Вкратце: с помощью UIBezierPath мы создадим фигуру, добавим ее на CAShapeLayer, а затем экземпляр CAShapeLayer добавим на CAReplicatorLayer, который "раскопируем" и заставим вращаться с помощью анимации (на самом деле копии будут менять прозрачность и это будет создавать эффект вращения).

Я сразу приведу пример кода, и откомментирую его - такой стиль повествования мне нравится больше чем отдельные куски кода с пояснениями, т. к. сразу можно скопировать себе и посмотреть:

final class CustomSpinnerSimple: UIView {
	// MARK: - Properties
	/// Объявляем нужные нам переменные для CAReplicatorLayer
	private lazy var replicatorLayer: CAReplicatorLayer = {
		let caLayer = CAReplicatorLayer()
		return caLayer
	}()

	/// и CAShapeLayer:
	private lazy var shapeLayer: CAShapeLayer = {
		let shapeLayer = CAShapeLayer()
		return shapeLayer
	}()

	/// Переменная для названия анимации (используем ниже)
	private let keyAnimation = "opacityAnimation"

	// MARK: - Init
	/// Удобный инициализатор
	/// - Parameter squareLength: длина стороны квадрата(вью)
	/// в котором будет спиннер
	/// По умполчанию спиннер устанавливается в центр экрана
	convenience init(squareLength: CGFloat) {
		let mainBounds = UIScreen.main.bounds
		let rect = CGRect(origin: CGPoint(x: (mainBounds.width-squareLength)/2,
										  y: (mainBounds.height-squareLength)/2),
						  size: CGSize(width: squareLength, height: squareLength))
		self.init(frame: rect)
	}

	/// Инициализатор через frame, который позволяет установить спиннер
	/// в любое место экрана
	/// - Parameter frame: фрейм в котором будет спиннер
	override init(frame: CGRect) {
		super.init(frame: frame)
		// добавляем replicatorLayer на слой нашего класса:
		layer.addSublayer(replicatorLayer)
		// добавляем shapeLayer на replicatorLayer:
		replicatorLayer.addSublayer(shapeLayer)
	}

	/// Обязательный нициализатор,
	/// available(*, unavailable) - означает что он не будет отображаться
	///в подсказке при создании класса
	@available(*, unavailable)
	required init?(coder: NSCoder) {
		fatalError("init(coder:) has not been implemented")
	}

	override func layoutSubviews() {
		super.layoutSubviews()
		// С помощью UIBezierPath рисуем круг и отображаем
		/// на нашем shapeLayer
		let size = min(bounds.width/2, bounds.height/2)
		let rect = CGRect(x: size/4, y: size/4, width: size/4, height: size/4)
		let path = UIBezierPath(ovalIn: rect)
		shapeLayer.path = path.cgPath

		// Устанавливаем размеры для replicatorLayer
		replicatorLayer.frame = bounds
		replicatorLayer.position = CGPoint(x: size, y:  size)
	}

	// MARK: - Animation's public functions

	/// Функция для запуска анимации
	/// - Parameters:
	///   - delay: Время анимации, чем меньше значение,
	/// тем быстрее будет анимация
	///   - replicates: количество реплик, то есть экземляров класса replicatorLayer
	func startAnimation(delay: TimeInterval, replicates: Int) {
		replicatorLayer.instanceCount = replicates
		replicatorLayer.instanceDelay = delay
		// Определяем преобразование для реплики - следующая реплика будет
		// повернута на угол angle, относительно предыдущей
		let angle = CGFloat(2.0 * Double.pi) / CGFloat(replicates)
		replicatorLayer.instanceTransform = CATransform3DMakeRotation(angle, 0.0, 0.0, 1.0)

		// А далее сама анимация для нашего shapeLayer:
		shapeLayer.opacity = 0 // начальное значение прозрачности
		// анимация прозрачности:
		let opacityAnimation = CABasicAnimation(keyPath: "opacity")
		// от какого значения (1 - непрозрачно, 0 полностью прозрачно)
		opacityAnimation.fromValue = 0.1
		opacityAnimation.toValue = 0.8 // до какого значения

		// продолжительность:
		opacityAnimation.duration = Double(replicates) * delay
		// повторять бесконечно:
		opacityAnimation.repeatCount = Float.infinity
		// добавляем анимацию к слою по ключу keyAnimation:
		shapeLayer.add(opacityAnimation, forKey: keyAnimation)
	}

	/// Функция остановки анимации - удаляем ее по ключу keyAnimation с нашего слоя
	func stopAnimation() {
		guard shapeLayer.animation(forKey: keyAnimation) != nil else {
			return
		}
		shapeLayer.removeAnimation(forKey: keyAnimation)
	}

	// MARK: - Deinit
	/// Останавливаем анимацию при деините экземпляра
	deinit {
		stopAnimation()
	}
}

Далее, чтобы увидеть как это все выглядит и используется, создаем новый проект, помещаем туда класс CustomSpinnerSimple и создаем контроллер:

final class ViewControllerSimple: UIViewController {
	// Спиннер - размер 100
	private lazy var spinner: CustomSpinnerSimple = {
		let spinner = CustomSpinnerSimple(squareLength: 100)
		return spinner
	}()

	// Во viewDidLoad добавляю спиннер  и стартую анимацию
	override func viewDidLoad() {
		super.viewDidLoad()
		view.addSubview(spinner)
		spinner.startAnimation(delay: 0.04, replicates: 20)
	}
}

Запускаем проект, видим результат - вышло довольно прилично! Обратите внимание, меняя параметры delay, replicates и opacityAnimation.fromValue, opacityAnimation.toValue мы можем получать разные спиннеры, некоторые варианты я привел ниже:

Осторожно, очень красиво!
delay 0.04, replicates 20, fromValue 0.2, toValue 0.9
delay 0.04, replicates 20, fromValue 0.2, toValue 0.9
delay: 0.01, replicates: 120, fromValue 0.1, toValue 0.7
delay: 0.01, replicates: 120, fromValue 0.1, toValue 0.7
delay: 0.04, replicates: 12, fromValue 0.1, toValue 0.9
delay: 0.04, replicates: 12, fromValue 0.1, toValue 0.9

2. Усложняем, используя возможности UIBezierPath

Я немного переработал класс спиннера, а также вью контроллер, здесь опишу примерные шаги и результаты, а остальное можно будет посмотреть в проекте, ссылку оставлю ниже.

Итак, выше, для подложки shapeLayer мы использовали круг, к тому же с фиксированными для каждого случая width и height:

let size = min(bounds.width/2, bounds.height/2)
let rect = CGRect(x: size/4, y: size/4, width: size/4, height: size/4)
let path = UIBezierPath(ovalIn: rect)
shapeLayer.path = path.cgPath

А что если пойти дальше? Попробовать нарисовать другие фигуры с помощью UIBezierPath, а также поиграть с размерами. Я попробовал использовать квадрат и треугольник.

Квадрат рисуется аналогично кругу:

let size = min(bounds.width/2, bounds.height/2)
let rect = CGRect(x: size/4, y: size/4, width: size/4, height: size/4)
let path = UIBezierPath(rect: rect)
shapeLayer.path = path.cgPath

А треугольник рисуем с помощью линий, примерно так:

var tX = bounds.width/4
var tY = bounds.height/4

let pathTriangle = CGMutablePath()
let startPoint = CGPoint(x: bounds.width/8, y: bounds.width/8)
pathTriangle.move(to: startPoint)

let point0 = startPoint.applying(.init(translationX: 0, y: tY / 2))
pathTriangle.move(to: point0)

let point1 = startPoint.applying(.init(translationX: tX, y: 0))
pathTriangle.addLine(to: point1)

let point2 = startPoint.applying(.init(translationX: tX, y: tY))
pathTriangle.addLine(to: point2)

pathTriangle.addLine(to: point0)
shapeLayer.path = pathTriangle

Я преобразовал CustomSpinnerSimple, добавив в инициализатор параметр тип спиннера SpinnerType - это перечисление, для каждого значения которого создается свой UIBezierPath(CGMutablePath для треугольников), который отвечает за определенный вид отрисовки, получив тем самым возможность отображать на shapeLayer различные фигуры.

Для отображения сразу нескольких вариантов спиннера, во вью контроллер я поместил UICollectionView.

В итоге играясь теми же параметрами delay, replicates - я поставил рандомную генерацию значений этих параметров, а также параметрами tX и tY, которые влияют на размер, а для треугольник и на общий вид, я получил целую гамму новых спиннеров:

Новые спиннеры с различными CGMutablePath и UIBezierPath
Новые спиннеры с различными CGMutablePath и UIBezierPath

Можно добавить цвет к нашим спиннерам, он также будет рандомный:

shapeLayer.fillColor = UIColor.random.cgColor

Здесь я использовал расширение класса UIColor:

extension UIColor {
	static var random: UIColor {
		return UIColor(
			red: .random(in: 0...1),
			green: .random(in: 0...1),
			blue: .random(in: 0...1),
			alpha: 1.0
		)
	}
}

Получаем что то вроде этого:

Цветные спиннеры
Цветные спиннеры

3. Заключение

Согласитесь, получилось довольно веселое приключение, при этом мы использовали всего два класса СoreAnimations. Остальное - стандартные инструменты UIKit.

Думаю примерно понятно, куда копать, если захочется сделать свой спиннер. Можно продолжить и найти новые интересные спиннеры - просто использовав новый UIBezierPath, а также поиграв с параметрами, про которые я упоминал выше.

Код можно посмотреть здесь. В проекте две основные папки - Simple и Hard. Первая соответствует спиннеру и вью контроллеру, который я описывал в пункте 1. Вторая - кастомным спиннерам из пункта 2 этой статьи. Переключаться между ними можно просто изменив стартовый вью контроллер приложения:

ViewController для Hard, ViewControllerSimple для Simple
ViewController для Hard, ViewControllerSimple для Simple

Комментарии (2)


  1. kovserg
    03.04.2022 10:14
    +2

    А нельзя исользовать gif или svg, что бы было потом проще менять?
    image

    <svg xmlns="http://www.w3.org/2000/svg" width="575" height="6">
    <style>
    circle{animation:ball 2.5s cubic-bezier(0,1,1,0) infinite; fill:#bbb }
    #balls{animation:balls 2.5s linear infinite}
    #c2{animation-delay:.1s}
    #c3{animation-delay:.2s}
    #c4{animation-delay:.3s}
    #c5{animation-delay:.4s}
    @keyframes ball{0%,20%{transform:none}80%,to{transform:translateX(864px)}}
    @keyframes balls{0%{transform:translateX(-40px)}to{transform:translateX(30px)}}
    </style>
    <g id="balls">
    <circle cx="-115" cy="3" r="3"/>
    <circle cx="-130" cy="3" r="3" id="c2"/>
    <circle cx="-145" cy="3" r="3" id="c3"/>
    <circle cx="-160" cy="3" r="3" id="c4"/>
    <circle cx="-175" cy="3" r="3" id="c5"/>
    </g></svg>
    


    1. joker_in_the_pack Автор
      03.04.2022 11:22

      да, наверно можно было = ) Спасибо!