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.
ссылка на оригинал статьи https://habr.com/ru/articles/732640/
Добавить комментарий