TL;DR
Если вы не хотите споткнуться о те же подводные камни и потратить на создание компонента больше времени, чем ожидали - загляните в последний раздел статьи с финальным кодом.
Интро
Привет, меня зовут Тёма Загоскин, я разрабатываю крутые штуки в Авиасейлс - сервисе по покупке дешевых авиабилетов. Год назад мы начали с нуля разрабатывать новый модуль, что позволило нам использовать модный молодежный SwiftUI. Казалось бы, идеальный инструмент для легкой верстки и красивых анимаций, поэтому очередная задача написать кастомный Segmented Control казалась тривиальной, тем более, что стандартный компонент кастомизируется буквально никак.
![Системный компонент Системный компонент](https://habrastorage.org/getpro/habr/upload_files/e42/a9f/bc6/e42a9fbc6ecc686c55ab63235b56468a.png)
Но, как оказалось, иногда на одну строчку кода могут уйти целые выходные.
Разработка
Первым делом - прототип без анимаций и украшательств. Спасибо SwiftUI за то, что на верстку компонентов, в целом готовых к работе, тратится от силы пара минут.
![](https://habrastorage.org/getpro/habr/upload_files/1e2/3e3/d88/1e23e3d885fdedb32e59944e9f14982a.gif)
struct CustomPicker<Option: CustomPickerOption>: View {
// MARK: - Properties
@Binding var selection: Option
let options: [Option]
// MARK: - UI
var body: some View {
HStack(spacing: 2) {
ForEach(options) { option in
Segment(
title: option.title,
imageName: option.imageName,
isSelected: selection == option,
action: { selection = option }
)
}
}
.padding(4)
.background(Color.blue)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
private struct Segment: View {
// MARK: - Properties
let title: String
let imageName: String?
let isSelected: Bool
let action: () -> Void
@State private var isPressed: Bool = false
// MARK: - UI
var body: some View {
Button(action: action) {
HStack(spacing: 4) {
Text(title)
.font(.system(size: 16, weight: .semibold, design: .rounded))
imageName.map(Image.init(systemName:))
}
.foregroundColor((isSelected ? Color.black : .white).opacity(isPressed ? 0.7 : 1))
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background {
if isSelected {
Color.white
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
.animation(.default, value: isSelected)
}
.buttonStyle(CustomPickerSegmentButtonStyle(isPressed: $isPressed))
}
private var content: some View {
}
}
Достаточно для MVP, но не очень хочется оставлять этот элемент в таком виде. Как сделать простенькую анимацию слайдинга? Один из вариантов - использовать GeometryReader
, сохранять ширину и начальную координату каждого сегмента и менять оффсет вьюшки выбора по нажатию. Я же выбрал другую возможность - .matchedGeometryEffect(id: …, in: …)
, позволяющий синхронизировать вьюшки по id и namespace и делать с ними различные анимации (в нашем случае - перемещение) в одну строчку. Накидываем модификатор, добавляем анимацию:
var body: some View {
Button(action: action) {
HStack(spacing: 4) {
Text(title)
.font(.system(size: 16, weight: .semibold, design: .rounded))
imageName.map(Image.init(systemName:))
}
.foregroundColor((isSelected ? Color.black : .white).opacity(isPressed ? 0.7 : 1))
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background {
if isSelected {
Color.white
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.matchedGeometryEffect(id: backgroundID, in: namespaceID) // <- Some kind of magic
}
}
.animation(.default, value: isSelected)
}
.buttonStyle(CustomPickerSegmentButtonStyle(isPressed: $isPressed))
}
На двух элементах это выглядит хорошо, но когда их больше - плашка просто напросто перекрывает элементы, через которые проезжает:
![](https://habrastorage.org/getpro/habr/upload_files/9ad/442/0be/9ad4420be035d8c745c2015472a7422e.gif)
Это было ожидаемо, поэтому не отчаиваемся и движемся дальше. Какая у вас первая идея для перекрашивания текста в зависимости от плашки выбора? У меня - blendMode, который устанавливает стиль элемента в зависимости от его пересечения с другими вьюшками. К сожалению, этот вариант я отмел сразу, ведь мы не можем, например, дать цвет определенный цвет нужным компонентам сами. Тогда я начал смотреть в сторону черного оверлея с модификатором mask под выбранный контент. Появляется красивая анимация поэтапной смены цвета текста, но остается проблема с тем, что невыбранные опции не становятся черными при пересечении плашки выбора.
Видимо, от blendMode не уйти. Тогда надо залезть в шпаргалку и разобраться, как же все-таки можно достичь нужного варианта. Накидываем .blendMode(.difference)
на контент, сверху оверлей с тем же контентом и .blendMode(.overlay)
, но у нас получается какая-то каша:
![](https://habrastorage.org/getpro/habr/upload_files/883/404/4c3/8834044c3e91982914f370b0e7a18d9d.png)
Что ж, все-таки придется положить дополнительный пикер всех вариантов blendMode и перебирать. Так, попробовав все опции и сверившись со шпаргалкой, приходим к выводу, что надо положить между контентом и оверлеем еще один оверлей с контентом и .blendMode(.hue)
. Бинго, все красится как надо! А если еще и накинуть модификатор .transition(.offset())
, задающий тип перехода, то пропадает портящий всю картину fade in:
![](https://habrastorage.org/getpro/habr/upload_files/a30/d5b/f75/a30d5bf75ec5140a2c3b586c623b1828.gif)
private struct Segment: View {
// MARK: - Properties
let title: String
let imageName: String?
let isSelected: Bool
let backgroundID: String
let namespaceID: Namespace.ID
let action: () -> Void
@State private var isPressed: Bool = false
// MARK: - UI
var body: some View {
Button(action: action) {
content
.blendMode(.difference)
.overlay(
content
.blendMode(.hue)
)
.overlay(
content
.blendMode(.overlay)
)
.background {
if isSelected {
Color.white
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.transition(.offset()) // <- Improve animation
.matchedGeometryEffect(id: backgroundID, in: namespaceID)
}
}
.animation(.default, value: isSelected)
}
.buttonStyle(CustomPickerSegmentButtonStyle(isPressed: $isPressed))
}
private var content: some View {
HStack(spacing: 4) {
Text(title)
.font(.system(size: 16, weight: .semibold, design: .rounded))
imageName.map(Image.init(systemName:))
}
.foregroundColor(.white.opacity(isPressed ? 0.7 : 1))
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
}
Но SwiftUI был бы не SwiftUI, если бы все было так просто. Проверяем это же в обратную сторону и...
![](https://habrastorage.org/getpro/habr/upload_files/b28/7bd/917/b287bd917f4f2439235d4444843d8985.gif)
Итоговый результат
Промучившись какое-то время и перебрав всевозможные статьи про смену порядка модификаторов, я решил провести еще один эксперимент. По видео кажется, что проблема может быть в положении элементов по оси Z. Да, в коде я не использую ZStack’и, но попробовать стоит. Накидываем zIndex
на сегмент в разных конфигурациях, понимаем, что выбранный элемент надо ставить ниже всех остальных, чтоб его бэкграунд точно был на самом нижнем уровне, и вуаля, элемент наконец готов улетать в продакшн.
![](https://habrastorage.org/getpro/habr/upload_files/345/618/80b/34561880bc724eb11f355b270c677e6c.gif)
struct CustomPicker<Option: CustomPickerOption>: View {
// MARK: - Properties
@Binding var selection: Option
let options: [Option]
@Namespace private var namespaceID
private let buttonBackgroundID: String = "buttonOverlayID"
// MARK: - UI
var body: some View {
HStack(spacing: 2) {
ForEach(options) { option in
Segment(
title: option.title,
imageName: option.imageName,
isSelected: selection == option,
backgroundID: buttonBackgroundID,
namespaceID: namespaceID,
action: { selection = option }
)
.zIndex(selection == option ? 0 : 1) // <- Seriously?
}
}
.padding(4)
.background(Color.blue)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
extension CustomPicker {
private struct Segment: View {
// MARK: - Properties
let title: String
let imageName: String?
let isSelected: Bool
let backgroundID: String
let namespaceID: Namespace.ID
let action: () -> Void
@State private var isPressed: Bool = false
// MARK: - UI
var body: some View {
Button(action: action) {
content
.blendMode(.difference)
.overlay(
content
.blendMode(.hue)
)
.overlay(
content
.blendMode(.overlay)
)
.background {
if isSelected {
Color.white
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.transition(.offset())
.matchedGeometryEffect(id: backgroundID, in: namespaceID)
}
}
.animation(.default, value: isSelected)
}
.buttonStyle(CustomPickerSegmentButtonStyle(isPressed: $isPressed))
}
private var content: some View {
HStack(spacing: 4) {
Text(title)
.font(.system(size: 16, weight: .semibold, design: .rounded))
imageName.map(Image.init(systemName:))
}
.foregroundColor(.white.opacity(isPressed ? 0.7 : 1))
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
}
}
One more thing
Готовый элемент, сделанный по дизайну - это безусловно хорошо, но иногда требуется возможность переиспользовать компоненты, а иногда даже немного кастомизировать. Я постарался сделать segmented control максимально гибким, поэтому если вы искали что-то на замену дефолтному - feel free to use CustomizableSegmentedControl. Подключается как через SPM, так и через CocoaPods.