Привет, Хабр!

Дизайн играет важную роль в мобильном приложении, напрямую влияя на его успех. На этапе проектирования интерфейса часто отдается предпочтение нестандартным, иногда даже интерактивным, элементам, которые будут притягивать взгляд, способствуя повышению показателя 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
}

Рассмотрим подробнее, что происходит в данном методе:

  1. Создаем и инициализируем константу path типа UIBezierPath.

  2. Перемещаемся в точку начала системы координат, которая всегда находится в левом верхнем углу.

  3. Добавляем линию в точку, которая соответствует правому верхнему углу таб-бара.

  4. Добавляем линию в точку, которая соответствует правому нижнему углу таб-бара.

  5. Добавляем линию в точку, которая соответствует левому нижнему углу таб-бара.

  6. Закрываем путь, по сути перемещаясь из точки левого нижнего угла в начальную точку, то есть в верхний левый угол.

  7. Возвращаем путь в виде объекта 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
}

Рассмотрим подробнее, что происходит в данном методе:

  1. Создаем и инициализируем константу path типа UIBezierPath.

  2. Для отрисовки полукруга используем метод addArc, первым параметром которого является центр полукруга. Это точка в центре таб-бара по оси x и с отступом вниз на 12 пикселей по оси y (по дизайну полукруг выступает над таб-баром на 15 пикселей).

  3. Указываем радиус полукруга.

  4. Указываем начальный угол.

  5. Указываем конечный угол.

  6. Указываем направление отрисовки полукруга – по часовой стрелке.

  7. Возвращаем путь в виде объекта 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
}

Здесь мы:

  1. Объявляем константу shapeLayer типа CAShapeLayer для создания слоя обводки по периметру таб-бара. У нее указываем путь, цвет обводки, цвет заливки и ширину линии.

  2. Аналогично поступаем с константой circleLayer, которая также имеет тип CAShapeLayer, для создания слоя обводки полукруга. Указываем аналогичные параметры.

  3. Проверяем, были ли данные слои добавлены ранее. Если да, подменяем их только что созданными, если нет – добавляем созданные слои, используя метод insertSublayer.

  4. Сохраняем созданные слои в соответствующие свойства класса.

Данный метод будем вызывать в переопределяемом методе 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
        }
    }
}

Здесь можно увидеть довольно простую логику:

  1. Фиксируем позицию нажатой кнопки.

  2. Проверяем, совпадает ли выбранная позиция с позицией middleButton, которая равна 1 (позиция кнопки соответствует позиции ViewController’а в массиве viewControllers класса MainTabBarController).

  3. Если да, меняем цвет кнопки на зеленый.

  4. Если нет, меняем цвет кнопки на красный.

UIResponderChain

На данном этапе кажется, что цель достигнута, но мы можем заметить два интересных момента:

  1. При нажатии на круглую кнопку происходит смена ViewController'а, то есть выбор tabBarItem. Но ведь мы не назначили никакое действие на нажатие кнопки.

  2. Если нажать на круглую кнопку чуть выше границы таб-бара, ничего не произойдет (а мы хотим, чтобы был выбран соответствующий 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. Возвращаем truesubview может обработать касание.

3. Если проход по subview не дал результата, возвращаем false – касание произошло за пределами таб-бара и всех его subview. Если касание произошло в границах таб-бара (pointIsInside == true), нам не придется пробегаться по subview таб-бара. Произойдет автоматический проход по иерархии UIResponderChain, начиная от самой "глубокой" subview.

Чтобы наша кнопка выполнила некое действие при обработке касания, вернемся в класс MainTabBarController и добавим следующую строку в замыкание, возвращающее middleButton:

middleButton.addTarget(self, action: #selector(didPressMiddleButton), for: .touchUpInside)

Рассмотрим входные параметры метода addTarget(_:action:for:):

  1. target – в рамках этого объекта произведем поиск метода, который будет вызван при нажатии на кнопку;

  2. actionselector метода, который будет вызван;

  3. controlEvents – событие, при котором произойдет вызов метода.

Внутри класса MainTabBarController объявим метод didPressMiddleButton(), который будет изменять значение выбранного tabBarItem и менять цвет кнопки на зеленый. Для того, чтобы использовать метод в качестве selector, нужно пометить его как @objc:

@objc
private func didPressMiddleButton() {
	selectedIndex = 1
	middleButton.backgroundColor = greenColor
}

Готово, мы получили полноценно функционирующую круглую кнопку в центре таб-бара.

Ссылка на репозиторий с проектом

Использованные материалы и полезные ресурсы по теме