Как я разрабатывал кастомный Segmented Control на SwiftUI

TL; DR

Если вы не хотите споткнуться о те же подводные камни и потратить на создание компонента больше времени, чем ожидали — загляните в последний раздел статьи с финальным кодом.

Интро

Привет, меня зовут Тёма Загоскин, я разрабатываю крутые штуки в Авиасейлс — сервисе по покупке дешевых авиабилетов. Год назад мы начали с нуля разрабатывать новый модуль, что позволило нам использовать модный молодежный SwiftUI. Казалось бы, идеальный инструмент для легкой верстки и красивых анимаций, поэтому очередная задача написать кастомный Segmented Control казалась тривиальной, тем более, что стандартный компонент кастомизируется буквально никак.

Системный компонент

Системный компонент

Но, как оказалось, иногда на одну строчку кода могут уйти целые выходные.

Разработка

Первым делом — прототип без анимаций и украшательств. Спасибо SwiftUI за то, что на верстку компонентов, в целом готовых к работе, тратится от силы пара минут.

1e23e3d885fdedb32e59944e9f14982a.gif

struct CustomPicker: 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))
}

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

9ad4420be035d8c745c2015472a7422e.gif

Это было ожидаемо, поэтому не отчаиваемся и движемся дальше. Какая у вас первая идея для перекрашивания текста в зависимости от плашки выбора? У меня — blendMode, который устанавливает стиль элемента в зависимости от его пересечения с другими вьюшками. К сожалению, этот вариант я отмел сразу, ведь мы не можем, например, дать цвет определенный цвет нужным компонентам сами. Тогда я начал смотреть в сторону черного оверлея с модификатором mask под выбранный контент. Появляется красивая анимация поэтапной смены цвета текста, но остается проблема с тем, что невыбранные опции не становятся черными при пересечении плашки выбора. 

Видимо, от blendMode не уйти. Тогда надо залезть в шпаргалку и разобраться, как же все-таки можно достичь нужного варианта. Накидываем .blendMode(.difference) на контент, сверху оверлей с тем же контентом и .blendMode(.overlay), но у нас получается какая-то каша:

8834044c3e91982914f370b0e7a18d9d.png

Что ж, все-таки придется положить дополнительный пикер всех вариантов blendMode и перебирать. Так, попробовав все опции и сверившись со шпаргалкой, приходим к выводу, что надо положить между контентом и оверлеем еще один оверлей с контентом и .blendMode(.hue). Бинго, все красится как надо!  А если еще и накинуть модификатор .transition(.offset()), задающий тип перехода, то пропадает портящий всю картину fade in:

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, если бы все было так просто. Проверяем это же в обратную сторону и…

b287bd917f4f2439235d4444843d8985.gif

Итоговый результат

Промучившись какое-то время и перебрав всевозможные статьи про смену порядка модификаторов, я решил провести еще один эксперимент. По видео кажется, что проблема может быть в положении элементов по оси Z. Да, в коде я не использую ZStack«и, но попробовать стоит. Накидываем zIndex на сегмент в разных конфигурациях, понимаем, что выбранный элемент надо ставить ниже всех остальных, чтоб его бэкграунд точно был на самом нижнем уровне, и вуаля, элемент наконец готов улетать в продакшн.

34561880bc724eb11f355b270c677e6c.gif

struct CustomPicker: 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.

© Habrahabr.ru