Привет, Хабр!
Дизайн играет важную роль в мобильном приложении, напрямую влияя на его успех. На этапе проектирования интерфейса часто отдается предпочтение нестандартным, иногда даже интерактивным, элементам, которые будут притягивать взгляд, способствуя повышению показателя user retention (удержание пользователей).
В данной статье рассмотрим один из таких случаев: таб-бар с круглой кнопкой в центре, которая будет изменять свой цвет при нажатии с условного красного на зеленый.
Представленный способ ориентирован на начинающих iOS-разработчиков.
Итак, начнем.
Стандартная реализация
UIKit
предоставляет iOS-разработчикам компонент для реализации навигации по приложению с помощью таб-бара – UITabBarController
. Данный компонент прост в применении и производит всю базовую отрисовку таб-бара за пользователя, достаточно указать ViewController
’ы, которые будут отображаться при нажатии на кнопки таб-бара, а также задать параметры tabBarItem.title
и tabBarItem.image
у каждого ViewController
. Именно этим способом, с некоторыми доработками, мы и воспользуемся при реализации задуманного дизайна.
Подготовка
Создадим пустой проект в Xcode, перейдем в файл Main.storyboard
выберем предложенный ViewController
. В строке меню перейдем в Editor -> Embed In
и выберем Tab Bar Controller
. Это позволит нам обернуть наш ViewController
вTabBarController
, который будет являться начальным контроллером при входе в приложение.
Удаляем tabBarItem
. Впоследствии мы добавим его через код. Должно получиться следующее состояние:
Вернемся в файл ViewController.swift
и переименуем наш класс в MainTabBarController
, нажав на название класса с зажатой клавишей Command и выбрав Rename
в контекстном меню:
Отнаследуемся от UITabBarController
вместо UIViewController
:
В файле Main.storyboard
у TabBarController
укажем класс MainTabBarController
:
В классе MainTabBarController
создадим константу диаметра средней кнопки (по дизайну он равен 42), константы для цветов, с которыми предстоит работать, и свойство самой кнопки:
private let middleButtonDiameter: CGFloat = 42
private let redColor: UIColor = UIColor(red: 254.0 / 255.0, green: 116.0 / 255.0, blue: 96.0 / 255.0, alpha: 1.0)
private let greenColor: UIColor = UIColor(red: 102.0 / 255.0, green: 166.0 / 255.0, blue: 54.0 / 255.0, alpha: 1.0)
private lazy var middleButton: UIButton = {
let middleButton = UIButton()
middleButton.layer.cornerRadius = middleButtonDiameter / 2
middleButton.backgroundColor = redColor
middleButton.translatesAutoresizingMaskIntoConstraints = false
return middleButton
}()
Скругление кнопки задается через свойство layer.cornerRadius
. Для получения круглой кнопки диаметр надо разделить на 2.
Создадим UIImageView
для отображения иконки сердца внутри кнопки.
private lazy var heartImageView: UIImageView = {
let heartImageView = UIImageView()
heartImageView.image = UIImage(systemName: "heart.fill")
heartImageView.tintColor = .white
heartImageView.translatesAutoresizingMaskIntoConstraints = false
return heartImageView
}()
Объявим метод makeUI()
для построения интерфейса:
private func makeUI() {
// 1
tabBar.addSubview(middleButton)
middleButton.addSubview(heartImageView)
// 2
NSLayoutConstraint.activate([
// 2.1
middleButton.heightAnchor.constraint(equalToConstant: middleButtonDiameter),
middleButton.widthAnchor.constraint(equalToConstant: middleButtonDiameter),
// 2.2
middleButton.centerXAnchor.constraint(equalTo: tabBar.centerXAnchor),
middleButton.topAnchor.constraint(equalTo: tabBar.topAnchor, constant: -10)
])
// 3
NSLayoutConstraint.activate([
// 3.1
heartImageView.heightAnchor.constraint(equalToConstant: 15),
heartImageView.widthAnchor.constraint(equalToConstant: 18),
// 3.2
heartImageView.centerXAnchor.constraint(equalTo: middleButton.centerXAnchor),
heartImageView.centerYAnchor.constraint(equalTo: middleButton.centerYAnchor)
])
}
Рассмотрим подробнее, что происходит в данном методе:
1. Добавляем middleButton
в качестве subview
наtabBar
, а также heartImageView
в качестве subview
на middleButton
;
2. Активируем констрейнты для middleButton
:
2.1. Фиксируем высоту и ширину в соответствии с дизайном;
2.2. Фиксируем центр по оси x
и отступ от верхней границы: по дизайну кнопка выступаем над таб-баром на 10 пикселей.
3. Активируем констрейнты для heartImageView
:
3.1. Фиксируем высоту и ширину в соответствии с дизайном;
3.2. Фиксируем центр по осям x
и y
для размещения heartImageView
по центру middleButton
.
Вызовем метод makeUI()
в методе viewDidLoad()
класса MainTabBarController
:
override func viewDidLoad() {
super.viewDidLoad()
makeUI()
}
Скомпилируем и запустим проект. У нас должно получиться следующее:
Работа с CAShapeLayer
По дизайну у нас есть обводка над кнопкой в форме полукруга. Отрисована она будет с помощью CAShapeLayer
, подкласса CALayer
.
Для её реализации нам надо создать отдельный файл, назовём его CustomTabBar.swift
, и объявить в нем одноимённый класс, который будет наследоваться от UITabBar
:
import UIKit
final class CustomTabBar: UITabBar {
}
В файле Main.storyboard
выберем таб-бар на MainTabBarController
и укажем, что он будет реализован классом CustomTabBar
:
Внутри класса CustomTabBar
объявим вычисляемые свойства для работы с таб-баром, а также константу для радиуса полукруга обводки:
private var tabBarWidth: CGFloat { self.bounds.width }
private var tabBarHeight: CGFloat { self.bounds.height }
private var centerWidth: CGFloat { self.bounds.width / 2 }
private let circleRadius: CGFloat = 27
Объявим метод для вычисления границ обводки таб-бара, который будет возвращать CGPath
– буквально путь, по которому мы будем двигаться, рисуя обводку:
private func shapePath() -> CGPath {
let path = UIBezierPath() // 1
path.move(to: CGPoint(x: 0, y: 0)) // 2
path.addLine(to: CGPoint(x: tabBarWidth, y: 0)) // 3
path.addLine(to: CGPoint(x: tabBarWidth, y: tabBarHeight)) // 4
path.addLine(to: CGPoint(x: 0, y: tabBarHeight)) // 5
path.close() // 6
return path.cgPath // 7
}
Рассмотрим подробнее, что происходит в данном методе:
Создаем и инициализируем константу
path
типаUIBezierPath
.Перемещаемся в точку начала системы координат, которая всегда находится в левом верхнем углу.
Добавляем линию в точку, которая соответствует правому верхнему углу таб-бара.
Добавляем линию в точку, которая соответствует правому нижнему углу таб-бара.
Добавляем линию в точку, которая соответствует левому нижнему углу таб-бара.
Закрываем путь, по сути перемещаясь из точки левого нижнего угла в начальную точку, то есть в верхний левый угол.
Возвращаем путь в виде объекта
CGPath
.
Аналогичным способом получим CGPath
для обводки полукруга:
private func circlePath() -> CGPath {
let path = UIBezierPath() // 1
path.addArc(withCenter: CGPoint(x: centerWidth, y: 12), // 2
radius: circleRadius, // 3
startAngle: 180 * .pi / 180, // 4
endAngle: 0 * 180 / .pi, // 5
clockwise: true) // 6
return path.cgPath // 7
}
Рассмотрим подробнее, что происходит в данном методе:
Создаем и инициализируем константу
path
типаUIBezierPath
.Для отрисовки полукруга используем метод
addArc
, первым параметром которого является центр полукруга. Это точка в центре таб-бара по осиx
и с отступом вниз на 12 пикселей по осиy
(по дизайну полукруг выступает над таб-баром на 15 пикселей).Указываем радиус полукруга.
Указываем начальный угол.
Указываем конечный угол.
Указываем направление отрисовки полукруга – по часовой стрелке.
Возвращаем путь в виде объекта
CGPath
.
Приступим к непосредственной отрисовке, для которой объявим метод drawTabBar()
, перед этим объявив два опциональных свойства для хранения рисуемых CALayer
:
private var shapeLayer: CALayer?
private var circleLayer: CALayer?
private func drawTabBar() {
// 1
let shapeLayer = CAShapeLayer()
shapeLayer.path = shapePath()
shapeLayer.strokeColor = UIColor.lightGray.cgColor
shapeLayer.fillColor = UIColor.white.cgColor
shapeLayer.lineWidth = 1.0
// 2
let circleLayer = CAShapeLayer()
circleLayer.path = circlePath()
circleLayer.strokeColor = UIColor.lightGray.cgColor
circleLayer.fillColor = UIColor.white.cgColor
circleLayer.lineWidth = 1.0
// 3
if let oldShapeLayer = self.shapeLayer {
self.layer.replaceSublayer(oldShapeLayer, with: shapeLayer)
} else {
self.layer.insertSublayer(shapeLayer, at: 0)
}
if let oldCircleLayer = self.circleLayer {
self.layer.replaceSublayer(oldCircleLayer, with: circleLayer)
} else {
self.layer.insertSublayer(circleLayer, at: 1)
}
// 4
self.shapeLayer = shapeLayer
self.circleLayer = circleLayer
}
Здесь мы:
Объявляем константу
shapeLayer
типаCAShapeLayer
для создания слоя обводки по периметру таб-бара. У нее указываем путь, цвет обводки, цвет заливки и ширину линии.Аналогично поступаем с константой
circleLayer
, которая также имеет типCAShapeLayer,
для создания слоя обводки полукруга. Указываем аналогичные параметры.Проверяем, были ли данные слои добавлены ранее. Если да, подменяем их только что созданными, если нет – добавляем созданные слои, используя метод
insertSublayer
.Сохраняем созданные слои в соответствующие свойства класса.
Данный метод будем вызывать в переопределяемом методе draw(_:)
нашего класса:
override func draw(_ rect: CGRect) {
drawTabBar()
}
Для тестирования вернемся в класс MainTabBarController
и добавим следующий код в метод makeUI()
:
// 1
let firstVC = UIViewController()
firstVC.view.backgroundColor = .yellow
firstVC.tabBarItem.title = "First VC"
firstVC.tabBarItem.image = UIImage(systemName: "1.circle")
// 2
let middleVC = UIViewController()
middleVC.view.backgroundColor = .green
middleVC.tabBarItem.title = "Middle VC"
// 3
let secondVC = UIViewController()
secondVC.view.backgroundColor = .blue
secondVC.tabBarItem.title = "Second VC"
secondVC.tabBarItem.image = UIImage(systemName: "2.circle")
// 4
viewControllers = [firstVC, middleVC, secondVC]
Здесь мы создаем несколько ViewController
’ов, указываем для них backgroundColor
, а также параметры, отвечающие за их отображение на таб-баре, и добавляем их в массив ViewController
’ов нашего UITabBarController
.
Скомпилируем и запустим проект. У нас получилось воспроизвести задуманный дизайн:
На этом этапе работает переключение между ViewController
’ами, но не реализован функционал изменения цвета круглой кнопки. Для этого объявим расширение класса MainTabBarController
и подпишемся под протокол UITabBarControllerDelegate
. В данном протоколе нас интересует метод tabBar(_:didSelect:)
, который срабатывает при нажатии на любую кнопку таб-бара. Переопределим данный метод:
extension MainTabBarController: UITabBarControllerDelegate {
override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
let selectedIndex = self.tabBar.items?.firstIndex(of: item) // 1
if selectedIndex != 1 { // 2
middleButton.backgroundColor = redColor // 3
} else {
middleButton.backgroundColor = greenColor // 4
}
}
}
Здесь можно увидеть довольно простую логику:
Фиксируем позицию нажатой кнопки.
Проверяем, совпадает ли выбранная позиция с позицией
middleButton
, которая равна 1 (позиция кнопки соответствует позицииViewController
’а в массивеviewControllers
классаMainTabBarController
).Если да, меняем цвет кнопки на зеленый.
Если нет, меняем цвет кнопки на красный.
UIResponderChain
На данном этапе кажется, что цель достигнута, но мы можем заметить два интересных момента:
При нажатии на круглую кнопку происходит смена
ViewController
'а, то есть выборtabBarItem
. Но ведь мы не назначили никакое действие на нажатие кнопки.Если нажать на круглую кнопку чуть выше границы таб-бара, ничего не произойдет (а мы хотим, чтобы был выбран соответствующий
tabBarItem
и поменялсяViewController
).
Почему же так происходит?
Ответ кроется в UIResponderChain
– иерархии объектов UIResponder
, которые отвечают за обработку события, в нашем случае – касания экрана.
Если view
(в нашем случае – круглая кнопка) не может обработать касание, происходит переход вверх по иерархии к parentView
(в нашем случае – таб-бар), далее – основная UIView
ViewController
'а, сам UIViewController
, UIWindow
, UIApplication
и, наконец, UIApplicationDelegate
.
Так как под нашей круглой кнопкой находится tabBarItem
, который может обработать касание, цепочка прервется на parentView
круглой кнопки, произойдет смена представленного ViewController
'а.
Что же делать с нажатием на область кнопки, которая выступает за границу таб-бара?
Проход по иерархии view
реализован с помощью метода hitTest(_:with:)
UIKit uses view-based hit-testing to determine where touch events occur. Specifically, UIKit compares the touch location to the bounds of view objects in the view hierarchy. The
hitTest(_:with:)
method of UIView traverses the view hierarchy, looking for the deepest subview that contains the specified touch, which becomes the first responder for the touch event.
Внутри него вызывается метод point(inside:with:)
, определяющий, должна ли view
обработать событие касания:
Returns a Boolean value indicating whether the receiver contains the specified point.
При этом официальная документация Apple гласит:
If a touch location is outside of a view’s bounds, the
hitTest(_:with:)
method ignores that view and all of its subviews.
То есть проход мы начнем не с subview
таб-бара (круглая кнопка), а с корневой UIView
ViewController
'a, которая, естественно, никак не обработает касание, как и остальные объекты UIResponder
в иерархии.
Чтобы обработать касание на части круглой кнопки, которая находится за пределами таб-бара, нам необходимо переопределить метод point(inside:with:)
в классе CustomTabBar
:
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let pointIsInside = super.point(inside: point, with: event) // 1
if pointIsInside == false { // 2
for subview in subviews { // 2.1
let pointInSubview = subview.convert(point, from: self) // 2.2
if subview.point(inside: pointInSubview, with: event) { // 2.3
return true // 2.3.1
}
}
}
return pointIsInside // 3
}
Здесь происходит следующее:
1. Проверяем, находится ли точка касания в границах (bounds
) таб-бара (в нашем случае получаем false
).
2. Если касание произошло вне bounds
таб-бара:
2.1. Проходимся по всем subview
таб-бара.
2.2. С помощью метода convert(_:from:)
находим данную точку в системе координат subview
. Если нажать на круглую кнопку ровно посередине у самого верха, получим CGPoint
с координатами x: 21.0, y: 0.0
(в системе координат таб-бара точка имеет координаты x: 195.0, y: -10.0
, где х
– центр экрана, а y
– величина выступа кнопки над таб-баром).
2.3. Если касание произошло внутри subview
:
2.3.1. Возвращаем true
– subview
может обработать касание.
3. Если проход по subview
не дал результата, возвращаем false
– касание произошло за пределами таб-бара и всех его subview
. Если касание произошло в границах таб-бара (pointIsInside == true
), нам не придется пробегаться по subview
таб-бара. Произойдет автоматический проход по иерархии UIResponderChain
, начиная от самой "глубокой" subview
.
Чтобы наша кнопка выполнила некое действие при обработке касания, вернемся в класс MainTabBarController
и добавим следующую строку в замыкание, возвращающее middleButton
:
middleButton.addTarget(self, action: #selector(didPressMiddleButton), for: .touchUpInside)
Рассмотрим входные параметры метода addTarget(_:action:for:)
:
target
– в рамках этого объекта произведем поиск метода, который будет вызван при нажатии на кнопку;action
–selector
метода, который будет вызван;controlEvents
– событие, при котором произойдет вызов метода.
Внутри класса MainTabBarController
объявим метод didPressMiddleButton()
, который будет изменять значение выбранного tabBarItem
и менять цвет кнопки на зеленый. Для того, чтобы использовать метод в качестве selector
, нужно пометить его как @objc
:
@objc
private func didPressMiddleButton() {
selectedIndex = 1
middleButton.backgroundColor = greenColor
}
Готово, мы получили полноценно функционирующую круглую кнопку в центре таб-бара.
Ссылка на репозиторий с проектом
nikit_ozz
Не будет ли выстрелом в ногу создание новых layer-ов при каждом вызове метода draw?
mobileSimbirSoft Автор
Уточните, пожалуйста, опасение связано с утечкой памяти? Если да, то никаких проблем не будет, потому что слои создаются локально в методе drawTabBar() и перестают существовать после выхода из метода. А свойства класса опциональные, поэтому утечки тоже быть не может.
Gorilka
Покажу примером
сколько будет элементов в конце вызова viewDidLoad?
mobileSimbirSoft Автор
Добрый день. В вашем случае в layers будет 4 элемента. В нашем случае мы всегда проверяем наличие слоев: если слой уже есть, он заменяется, а не добавляется, поэтому у нас их всегда остается 2
Gorilka
согласен, не заметил проверку
Но в проверке есть ошибка, если уже есть 2 слоя, то отработает только первый if, а остальные проигнорятся
Нужно для каждого слоя свой if сделать
Ну и если по хорошему, то нужно делать проверку, на существование слоев, в самом начале, чтоб не делать лишнюю работу, ведь по сути слои не меняются.
mobileSimbirSoft Автор
Спасибо, проверку действительно стоит делать отдельно для каждого слоя, при этом она находится в правильном месте, так как на момент проверки обновленный слой уже должен быть создан
nikit_ozz
Я имел ввиду лишние вызовы инициализаторов, время исполнения которых ненулевое. А это значит, что вопрос падения — это вопрос количества вызовов. К примеру, если в инициализаторе layer-а будет какое-то потенциально весомое вычисление, то при многократном вызове draw (например анимация) эти вычисления будут прогоняться зря. Конечно, в реальном примере это не поднимет использование cpu даже на процент, но все-же не думаю что хорошая практика — создавать объект на всякий случай и надеяться на оптимизацию)