Привет, Хабр! На связи Андрей – iOS разработчик из ecom.tech. Моя команда помогает различным маркетплейсам делать крутые вещи для их приложений.
В этой статье я поделюсь своим опытом работы с обратным отсчётом времени (на примере Мегамаркета) и расскажу, как поставить таймер самостоятельно.
Кажется, что таймер – простая для реализации вещь. Но если добавить сюда сжатые сроки, код (его рефакторинг часто откладывается по разным причинам), пласты бизнес-логики и UI-элементы – получим нетривиальную задачу.
Зачем нужен таймер
Таймер используется для планирования действий в приложении (например, если нужно задать пользователю ограниченное время на какое-то действие). Причины для появления таймера бывают разные, поэтому универсальная логика для реализации рано или поздно понадобится.
Рассмотрим процесс работы над таймером на реальном кейсе. В приложении Мегамаркет (один из крупнейших маркетплейсов), нас интересует место, куда чаще всего смотрят пользователи, ожидающие своего заказа – список заказов. Какие-то из них едут, какие-то пользователь сейчас примеряет. Каждый заказ находится в своём статусе.
Есть разные статусы заказа, а есть разные типы доставок. Самые популярные среди пользователей: «доставка по клику» и её прокаченная версия «доставка по клику с примеркой». В последнем случае у клиента есть два часа, чтобы решить, подходит ли ему вещь. Если вещь не подошла – курьер забирает товар и никаких хлопот с возвратом. Именно для этих типов доставки реализовывалась фича, в рамках которой я работал с таймером.
Требования к таймеру
Раньше вся информация отображалась в «карточке товара», а какую-то часть можно было посмотреть, провалившись в детальное описание.
После того, как появилась новая сущность «виджет» (которая должна находиться вверху, перед списком заказов) понадобились изменения. Виджетов может быть много, они скроллятся по горизонтали, отображают наиболее важную информацию по доставкам. Например: время, которое осталось у пользователя на примерку.
Таймер должен отображаться в виде кольца, которое показывает, сколько времени осталось до конца примерки. Время отображается так: «2 часа», «1.5 часа», а всё, что меньше часа, обновляется каждую минуту. Изначальное время примерки - хардкод на фронте - 2 часа.
Конечно, уточнив основные продуктовые пожелания, я отправился на поиски готового решения, чтобы нажать заветные cmd+c и cmd+v. Но нашёл либо туториал по общей работе с таймером, либо реализацию простого таймера с отображением одной статичной окружности на экране. Это явно не то, что было нужно. Пришлось писать собственное решение.
Первое, чем хочется заняться - провести глубокий ресерч и оценить все корнер кейсы начать разработку UI для таймера.
Назовем View нашего таймера - TimerView. Теперь начнем «рисовать». Для начала задаём две окружности с помощью CAShapeLayer. Первая окружность – статичная подложка, её мы назовем timerCircleFillLayer, а вторая – фиолетовая окружность, которая будет динамически меняться.
private let timerCircleFillLayer = CAShapeLayer()
private let timerTrackLayer = CAShapeLayer()
private let timeDuration: Int = 0
private lazy timeLabel = label()
.size(.square(40))
Можно заметить некие сокращения кода (timeLabel - label, на котором будет отображаться текст). Их описание хранятся в открытой библиотеке.
timeDuration – переменная, которая хранит в себе секунды, оставшиеся у пользователя на примерку.
Для конфигурации UI мы будем использовать публичный метод configure.
public func configure(with viewModel: TimerViewModeling) {
self.viewModel = viewModel
self.timerDuration = viewModel.initialTime
setupLayers()
setupLabel()
}
Теперь посмотрим что за методы setupLayers() и setupLabel()
private func setupLayers() {
timerTrackLayer.strokeColor = UIColor.bgLayer02Medium.cgColor
timerTrackLayer.lineWidth = .size04
timerTrackLayer.fillColor = UIColor.clear.cgColor
timerTrackLayer.lineCap = .round
timerCircleFillLayer.strokeColor = UIColor.feedbackTextInfo.cgColor
timerCircleFillLayer.lineWidth = .size04
timerCircleFillLayer.fillColor = UIColor.clear.cgColor
timerCircleFillLayer.lineCap = .round
timerCircleFillLayer.strokeStart = 0
timerCircleFillLayer.strokeEnd = CGFloat(timerDuration) / 7200.0
self.layer.addSublayer(timerTrackLayer)
self.layer.addSublayer(timerCircleFillLayer)
}
private func setupLabel() {
timeLabel.attributedText(timeToText(time: timerDuration))
.textAlignment(.center)
.numberOfLines(2)
self.addSubview(timeLabel)
}
В методе setupLayers мы обращаемся к свойствам CAShapeLayer, чтобы сконфигурировать всё, как нам надо:
strokeColor – окружности.
lineWidth – толщина окружности.
fillColor – цвет, которым закрашивается сам круг (круг и окружность тут важно отличать).
lineCap – описание формы, которую мы будем дальше рисовать.
strokeStart/strokeEnd – свойства, которые показывают, какую часть от окружности будет занимать timerCircleFillLayer (фиолетовая). По дефолту там стоят значения 0 и 1. При таких значениях окружность полностью закрашивается. Мы же конец ставим в значение CGFloat(timerDuration) / 7200.0 (7200 - это 2 часа в секундах), чтобы окружность закрашивала только тот сегмент, который остался для примерки у пользователя.
Когда мы задали основные параметры, нам нужно добавить в массив sublayers [CALayer] два новых sublayer - а.
В методе setupLabel всё достаточно просто – задаём текстовое описание времени, которое осталось для примерки. Метод timeToText просто переводит секунды в корректный текст для пользователя.
private func timeToText(time: Int) -> NSMutableAttributedString {
let timeToMinute = time / 60
switch timeToMinute {
case 91...120:
return ("2".apply(.bodyPrimaryRegular.textPrimary)
+ "\nчаса".apply(.captionPrimaryRegular.textSecondary))
.mutateParagraphStyle(.lineHeight(.size12))
case 61...90:
return ("1,5".apply(.bodyPrimaryRegular.textPrimary)
+ "\nчаса".apply(.captionPrimaryRegular.textSecondary))
.mutateParagraphStyle(.lineHeight(.size12))
case 0...60:
return ("\(timeToMinute)".apply(.bodyPrimaryRegular.textPrimary)
+ "\nмин".apply(.captionPrimaryRegular.textSecondary))
.mutateParagraphStyle(.lineHeight(.size12))
default:
return NSMutableAttributedString()
}
}
На этом "конфигурация" окружностей заканчивается, но мы ещё не сделали главное – не описали саму окружность.
Для начала определимся с местом, где мы это хотим сделать. LayoutSubviews подходит сюда:
override func layoutSubviews() {
super.layoutSubviews()
let radius: CGFloat = .size24
let arcCenter = CGPoint(x: self.bounds.midX, y: self.bounds.midY)
let arcPath = UIBezierPath(arcCenter: arcCenter,
radius: radius,
startAngle: -.pi / 2,
endAngle: .pi * 1.5,
clockwise: true)
timerTrackLayer.path = arcPath.cgPath
timerTrackLayer.position = CGPoint(x: bounds.midX - arcCenter.x, y: bounds.midY - arcCenter.y)
timerCircleFillLayer.path = arcPath.cgPath
timerCircleFillLayer.position = CGPoint(x: bounds.midX - arcCenter.x, y: bounds.midY - arcCenter.y)
timeLabel.center = CGPoint(x: bounds.midX, y: bounds.midY)
}
Сначала мы задаём радиус нашей окружности + положение её центра. Далее обратимся к геометрии и вспомним, что 360° - это 2*pi. Нас интересует вращение не от 0 до 2*pi, а от -pi / 2 до pi * 1.5 . Если немного подзабыта школьная геометрия, то вот ссылка.
Учитывая, что параметр clockwise отвечает за направление отрисовки, мы смогли нарисовать окружность. Теперь осталось применить наш arcPath к обоим слоям.
Есть один тонкий момент. Если бы время было не 2 часа, а меньше (минута, например), нам надо было бы добавить анимацию. Но в данной ситуации достаточно просто обновлять UI раз в минуту (эту логику опишу позже).
Кодовый контекст
Перед тем, как мы начнём прописывать остальной UI и логику, нам необходимо понять некий «кодовый контекст», в который мы хотим встроиться.
Список заказов – это UITableView. На проекте используется архитектура MVVM, логика наполнений таблицы вынесена туда. Исходя из абстрактного ТЗ, которое я описывал выше, следует, что нам надо добавить сверху ячейку, внутри которой будет коллекция со скроллом по горизонтали + по тапу на конкретную ячейку должна всплывать модалка с разными вариантами отображения, но её мы тут трогать не будем.
Итак, мы упомянули ячейку, обозначим её название – DeliveryWidgetsTableViewCell. Это обычная UITableViewCell, большая часть логики которой нам не нужна, так как она касается обработки нажатий. Но один момент мы подсветим – формирование горизонтальной коллекции.
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewFlowLayout)
.configure { collectionView in
collectionView.dataSource = self
collectionView.delegate = self
collectionView.backgroundColor = .clear
collectionView.showsHorizontalScrollIndicator = false
collectionView.registerClass(forCell: DeliveryWidgetsCollectionViewCell.self)
}
private lazy var collectionViewFlowLayout = UICollectionViewFlowLayout()
.configure { collectionViewFlowLayout in
collectionViewFlowLayout.scrollDirection = .horizontal
collectionViewFlowLayout.sectionInset = .symmetry(h: .spacing16, v: .spacing12)
collectionViewFlowLayout.minimumLineSpacing = .spacing16
}
Кажется, здесь ничего пояснять не надо – обычная настройка UICollectionView, ячейка внутри которой называется DeliveryWidgetsCollectionViewCell. Но в этой ячейке есть интересные для нас моменты.
Логика работы
Мы всё ещё не прописали логику работы таймера. Это было сделано специально, потому что первое, что хочется сделать – прописать логику таймера через стандартный Timer.scheduledTimer. Там, где мы делали UI. И такой вариант может вам подойти, если у вас есть один статичный таймер на экране. Но нам такое не подошло, потому что наш таймер – обычная View, которая при проскролле в сторону «умирает», останавливая наш таймер.
По этой причине необходимо перенести логику таймера «на уровень вверх»: в DeliveryWidgetCellViewModel – модель для нашей ячейки коллекции. Внутри этой модели есть много логики, которая отвечает за отображение ячейки, но я подсвечу именно то, что интересует нас:
public final class DeliveryWidgetCellViewModel {
let id: UUID
var updateUI: (() -> Void)?
var isFirstLaunch: Bool = true
//...
private weak var timer: Timer?
private var remainingTime: Int?
init(widgetItem: widgetItemType,
updateHandler: (() -> Void)?) {
//...
self.id = UUID()
setupWidget(widgetItem: widgetItem)
}
private func setupWidget(widgetItem: widgetItemType) {
//...
remainingTime = Int(widgetItem.options.fittingTimeTo.value)
//...
}
func startTimer() {
timer = Timer.scheduledTimer(timeInterval: 1,
target: self,
selector: #selector(updateTime),
userInfo: nil,
repeats: true)
if let timer {
RunLoop.current.add(timer, forMode: .common)
}
}
func stopTimer() {
timer?.invalidate()
timer = nil
}
func getRemainingTime() -> Int? {
return remainingTime
}
@objc private func updateTime() {
if let remainingTime {
self.remainingTime = (self.remainingTime ?? 0) - 1
if (remainingTime) % 60 == 59 {
DispatchQueue.main.async {
self.updateUI?()
}
}
if remainingTime <= 0 {
updateHandler
self.stopTimer()
}
}
}
Тут уже добавилось больше кода:
id – идентификатор конкретной ячейки, который нам нужен, чтобы корректно обновлять UI. У нас может происходить много обновлений UI-я разных ячеек во время скролла, поэтому мы должны понимать, что за ячейка сейчас на экране, чтобы не задать ей не её UI. Если мы не добавим этот id, то при скролле, когда таймер досчитает до значений, при котором надо перерисовать ячейку – мы изменим UI той ячейки, которая сейчас на экране(и неважно нужная эта ячейка или нет).
updateUI – клоужер, который вызывает обновления UI ячейки.
isFirstLaunch – параметр, который хранит информация о том, показывали ли мы ячейку пользователю хоть раз.
timer – сам таймер.
remainingTime – время, которое осталось на примерку товара у пользователя (значение таймера в данный момент).
setupWidget – функция, которая содержит в себе много разной логики виджет. Нас интересует, что именно там происходит выставление изначального значения для таймера.
getRemainingTime – обычный геттер для remainingTime.
Если что-то из параметров было непонятно (например id), то, возможно, станет, когда мы перейдем к самой ячейке. Теперь обратим своё внимание на ряд методов для таймера:
startTimer – тут мы инициализируем наш таймер и задаём с какой периодичностью (в секундах) будет вызываться метод updateTime. Не забываем перевести RunLoop в режим .common (это необходимо для корректной работы таймера, чтобы он не останавливался при скролле. Если хочется больше узнать про RunLoop, то в интернете много разных докладов на эту тему, но если нужна статья – пишите в комментариях).
stopTimer – метод, который останавливает таймер. На самом деле достаточно важно его вызывать в различных моментах, когда мы по каким-то причинам заново конфигурируем экран.
updateTime – метод, который отвечает за обновления UI, если выполняется условие/остановку таймера, когда тот доходит до нуля.
Когда мы разобрались с логикой модели – переходим к самой ячейке:
final class DeliveryWidgetsCollectionViewCell: UICollectionViewCell {
//...
private var timer = TimerView()
.size(.square(.size40))
private var updateHandler: (() -> Void)?
private var viewModel: DeliveryWidgetCellViewModel?
private var viewModelID: UUID?
// MARK: - Initialization
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
//...
@objc private func stopTimerBeforeReload() {
viewModel?.stopTimer()
}
// MARK: - Public methods
public func configure(with viewModel: DeliveryWidgetCellViewModel) {
self.viewModel = viewModel
self.viewModelID = viewModel.id
NotificationCenter.default.addObserver(self,
selector: #selector(stopTimerBeforeReload),
name: NSNotification.Name(rawValue: AppNotifications.orderListWillReload),
object: nil)
//...
if let timeForCircle = viewModel.getRemainingTime() {
//...
if viewModel.isFirstLaunch {
viewModel.startTimer()
viewModel.isFirstLaunch = false
}
timer.configure(
with: TimerViewModel(initialTime: timeForCircle,
updateHandler: { [weak self] in
self?.updateHandler?()
})
)
viewModel.updateUI = { [weak self, weak viewModel] in
guard let self, let viewModel, self.viewModelID == viewModel.id else { return }
self.configure(with: viewModel)
}
}
//...
}
override func removeFromSuperview() {
super.removeFromSuperview()
viewModel?.stopTimer()
}
// MARK: - Private methods:
private func setupUI() {
//...
}
}
В первой строке видим инициализацию таймера, который мы рисовали в самом начале. Чуть ниже мы объявляем нашу модель и её id. Тут хотелось бы обязательно подсветить вызов viewModel?.stopTimer() в ряде мест. Это необходимые шаги, которые надо предпринять, чтобы остановить таймер в определенных случаях.
Большинство кода тут –несложная логика, мы подсветили, зачем используем в configure NotificationCenter. Самое интересное для нас – ниже. Выпишем этот код отдельно:
if let timeForCircle = viewModel.getRemainingTime() {
//...
if viewModel.isFirstLaunch {
viewModel.startTimer()
viewModel.isFirstLaunch = false
}
timer.configure(
with: TimerViewModel(initialTime: timeForCircle,
updateHandler: { [weak self] in
self?.updateHandler?()
})
)
viewModel.updateUI = { [weak self, weak viewModel] in
guard let self, let viewModel, self.viewModelID == viewModel.id else { return }
self.configure(with: viewModel)
}
}
В самом начале мы проверяем, есть ли у нас таймер. Если он есть – проверяем, показывался ли этот таймер на экране. Если это первый показ, нам надо запускать таймер – если мы это не проверим, то в данной реализации таймер запускался бы много раз при появлении ячейки. Далее мы передаём несложную модельку для таймера (updateHandler - просто некий клоужер, который нам надо исполнять в таймере в определенный момент).
После мы прописываем обновление UI, которое нам нужно раз в минуту. Тут стоит обязательно обратить внимание на этот код:
self.viewModelID == viewModel.id
Это и есть та проверка, о которой я говорил ранее. Если у нас какой-то таймер вызывает обновление ячейки, то учитывая возможность скролла, мы не можем обновить то, что сейчас на экране. Нам обязательно надо проверить, что мы обновляем то, что нужно. Обновления UI выполняется через self.configure(with: viewModel).
Таймер готов!
Вот и всё. Мы смогли реализовать наш «усложненный таймер», поборов моменты, которые мешали корректной работе. На все эти моменты я сам натыкался, когда разрабатывал таймер. Это было очень интересно и непросто, но ради таких задач я и занимаюсь iOS-разработкой!
Этот код решает поставленную задачу на требуемом уровне. Конечно, он уже переработан: где-то применялись более красивые и логичные решения, но я рассказал о своем «полевом опыте».
Ваш бизнес может захотеть добавить/изменить какие-то фишки. Тогда советую набраться терпения, вспомнить базовую теорию при работе с таймером и пилить собственное решение. Пробуйте сами, не стесняйтесь обращаться за помощью к более опытным коллегам и будьте готовы много раз протестировать ваш функционал в самых разных ситуациях. Удачи!
house2008
Спасибо. Надеюсь функция
это для статьи так написано, иначе если время ожидание будет 3 часа то придется выпускать обновление, ну и хардкорные числа, для этого есть Interval или Number форматтеры, ну и локаль еще, если у меня не ru локаль я не хочу видеть запятую в 1,5 часа, а хочу видеть точку.
ios_dev_187 Автор
Это решение объективно не самое лучшее, оно писалось в первом приближении, когда шел процесс придумывания как это все сделать. Сейчас уже это работает иначе