Как стать автором
Обновить

HorizontalList с помощью SwiftUI

Время на прочтение11 мин
Количество просмотров4.6K

Вступление


SwiftUI — это современный UI framework, который позволяет разработчикам быстро и легко создавать собственные приложения на всех платформах Apple.


Используя простой, понятный декларативный стиль, разработчики могут создавать потрясающие пользовательские интерфейсы с плавной анимацией. SwiftUI экономит время разработчиков, предоставляя огромное количество готовых решений, включая Interface Layout, Dark Mode, Accessibility, интернационализацию и многое другое. Приложения SwiftUI работают нативно и невероятно быстро. А поскольку SwiftUI — это один и тот же API, встроенный в iOS, iPadOS, macOS, watchOS и tvOS, разработчики могут быстрее и проще создавать отличные нативные приложения для всех платформ Apple.


Звучит amazing, не правда ли?


Введение


SwiftUI был анонсирован на WWDC2019 и за последний год было написано множество статей, посвященных этому фреймворку. Поэтому в данной статье мы не будем заострять внимание на таких вещах, как



а сразу перейдем к практике и сделаем достаточно стандартную в повседневной жизни задачу — создание горизонтального списка.


Будет очень много кода и мало комментариев, впрочем, все как мы любим.


Глава 1. Что нам стоит горизонтальный ScrollView построить


Горизонтальный список можно создать достаточно просто. Для этого необходимо поместить HStack в ScrollView и заполнить HStack нашими элементами:


var body: some View {
    ScrollView(.horizontal) {
        HStack {
            ForEach(0...9, id: \.self) { index in
                SomeAmazingView(atIndex: index)
            }
        }
    }
}

Для практической наглядности создадим View, которая будет отображать список из 100 карточек. Каждая карточка будет отображать случайно сгенерированный смайлик и индекс самой карточки.


struct ContentView: View {

    struct Constants {
        static var itemsCount = 100
    }

    // MARK: - State    
    @State var items: [String] = []

    // MARK: - Initialization
    init() {
        items = generateData()
    }

    // MARK: - View
    var body: some View {
        ScrollView(.horizontal) {
            HStack {
                ForEach(0..<items.count, id: \.self) { index in
                    CardView(index: index, title: self.items[index])
                        .frame(width: 150, height: 200)
                        .padding(10)
                }
            }
        }
    }

    // MARK: - Private Helpers
    private func generateData() -> [String] {
        var data: [String] = []
        for _ in 0..<Constants.itemsCount {
            data.append(String.randomEmoji())
        }

        return data
    }
}

Запускаем и вуаля, как и обещала Apple — все нативно и невероятно быстро.


enter image description here




Но есть одна проблема — если количество данных увеличится, то мы столкнемся с проблемой.


    ...
    struct Constants {
        static var itemsCount = 1000
        ...
    }
    ...

Запускаем и ...


enter image description here


Глава 2. Если хочешь сделать что-то хорошо, сделай это сам


Для решения этой проблемы в UIKit мы бы использовали UICollectionView. Но, к сожалению, не всё, что было возможно при использовании UIKit, имеет аналог в SwiftUI.


Конечно, можно было бы использовать UICollectionView напрямую в SwiftUI. Как это сделать можно прочитать здесь. Но это уже не SwiftUI и определенно не наш путь.


На данный момент единственная структура в SwiftUI, которая загружает и отображает данные только по необходимости (on demand) это List.


@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
public struct List<SelectionValue, Content> : View where SelectionValue : Hashable, Content : View {
...
}

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension List {
    ...
    /// Creates a List that computes its rows on demand from an underlying
    /// collection of identified data.
    @available(watchOS, unavailable)
    public init<Data, RowContent>(_ data: Data, selection: Binding<Set<SelectionValue>>?, @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent) where Content == ForEach<Data, Data.Element.ID, HStack<RowContent>>, Data : RandomAccessCollection, RowContent : View, Data.Element : Identifiable
    }
    ...
}

Возьмем это решение от Apple и создадим схожее API для нашей структуры HorizontalList:


public struct HorizontalList<Content, Data> : View where Content : View, Data: RandomAccessCollection, Data.Element: Hashable {

    // MARK: - Properties
    private let data: [Data.Element]
    private let itemContent: (Data.Element) -> Content

    // MARK: - Initialization
    public init(_ data: Data, @ViewBuilder itemContent: @escaping (Data.Element) -> Content) {
        self.itemContent = itemContent

        if let range = data as? Range<Int> {
            self.data = Array(range.lowerBound..<range.upperBound) as! [Data.Element]
        } else if let closedRange = data as? ClosedRange<Int> {
            self.data = Array(closedRange.lowerBound..<closedRange.upperBound) as! [Data.Element]
        } else if let array = data as? [Data.Element] {
            self.data = array
        } else {
            fatalError("Unsupported data type.")
        }
    }

    // MARK: - View
    public var body: some View {
        ZStack {
            if !self.data.isEmpty {
                ForEach(0..<self.data.count, id: \.self) { index in
                    self.makeView(atIndex: index)
                }
            }
        }
    }

    // MARK: - Private Helpers
    private func makeView(atIndex index: Int) -> some View {
        let item = data[index]
        let content = itemContent(item)

        return content
    }
}

Обновим наш пример с карточками и будем использовать собственное решение:


var body: some View {
    HorizontalList(0..<items.count) { index in
        CardView(index: index, title: self.items[index])
            .frame(width: Constants.itemSize.width, height: Constants.itemSize.height)
            .padding(10)
    }
}

Запускаем и (через какое-то время..) видим, что карточки успешно загрузились и отобразились:


enter image description here


Глава 2. Это особая, Layout магия


Большую часть времени SwiftUI будет сам делать свою магию по расположению элементов и жизнь будет великолепной. Однако бывают случаи, когда нам требуется больше контроля для расположения наших собственных Views. Для этого у нас есть несколько инструментов. Одним из них является GeometryReader, его мы и будем использовать для получения информации о размерах и позициях элементов внутри него.


GeometryReader - A container view that defines its content as a function of its own size and coordinate space.


Вместо тысячи слов про GeometryReader, я бы посоветовал один раз перейти по ссылке и прочитать очень хорошую статью по теме.


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


В SwiftUI есть механизм, который позволяет добавлять некоторые атрибуты к View. Эти атрибуты называются "Preferences". При изменении этих атрибутов мы будем получать callback.


Первым делом нам необходимо создать структуру данных для атрибута. В ней мы будем хранить индекс элемента и его Rect. Структура должна поддерживать протокол Equatable.


struct ViewRectPreferenceData: Equatable {
    let index: Int
    let rect: CGRect
}

Следующим шагом создадим сам аттрибут, поддерживающий протокол PreferenceKey и содержащий в себе массив значений ViewRectPreferenceData.


struct ViewRectPreferenceKey: PreferenceKey {
    typealias Value = [ViewRectPreferenceData]

    static var defaultValue: [ViewRectPreferenceData] = []

    static func reduce(value: inout [ViewRectPreferenceData], nextValue: () -> [ViewRectPreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}

Так как нам нужно будет знать размеры элементов, а сделать это можно только с помощью GeometryReader, мы создадим специальную View, которая будет добавлена как child к нашим элементам и, как результат, иметь GeometryReader с размерами нашего элемента.


struct PreferenceSetterView: View {
    let index: Int
    let coordinateSpaceName: String

    var body: some View {
        GeometryReader { geometry in
            Rectangle()
                .fill(Color.clear)
                .preference(key: ViewRectPreferenceKey.self,
                            value: [ViewRectPreferenceData(index: self.index, rect: geometry.frame(in: .named(self.coordinateSpaceName)))])
        }
    }
}

Добавим созданный PreferenceSetterView к нашим элементам, как background:


private func makeView(atIndex index: Int) -> some View {
    ...
    return content
            .background(PreferenceSetterView(index: index, coordinateSpaceName: Constants.coordinateSpaceName))
}

При изменении Preference вызывается метод onPreferenceChange, в нем то мы и получим размеры отображенных на экране элементов и сохраним их в наш массив. Так как у нас горизонтальный список, также мы будем высчитывать отступ элементов согласно отступу и размеру предыдущего элемента.


...

struct  Constants {
    static  var  coordinateSpaceName: String {
        return  "HorizontalListCoordinateSpaceName"
    }
}

@State private var rects: [Int: CGRect] = [:]

...

public var body: some View {
    GeometryReader { geometry in
        ZStack {
            ...
         }
         .onPreferenceChange(ViewRectPreferenceKey.self) { preferences in
                for preference in preferences {
                    var rect = preference.rect
                    if let prevRect = self.rects[preference.index - 1] {
                        rect = CGRect(x: prevRect.maxX, y: rect.minY, width: rect.width, height: rect.height)
                    }

                    self.rects[preference.index] = rect
          }
          .coordinateSpace(name: Constants.coordinateSpaceName)
        }
}

Про Preferences eсть хорошая серия статей из 3-х частей:
Часть 1
Часть 2
Часть 3


Глава 3. Ты видишь только то, что тебе показывают


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


@State  private  var  visibleIndices: ClosedRange<Int> = 0...0
...
public var body: some View {
        GeometryReader { geometry in
            ZStack {
                if !self.data.isEmpty {
                    ForEach(self.model.visibleIndices, id: \.self) { index in
                        self.makeView(atIndex: index)
                     }
                 }
            }
            .onAppear() {
                self.updateVisibleIndices(geometry: geometry)
            }
            .onPreferenceChange(ViewRectPreferenceKey.self) { preferences in
                 ...
                 self.updateVisibleIndices(geometry: geometry)
             }
        }
}

...

private func updateVisibleIndices(geometry: GeometryProxy) {
    let bounds = geometry.frame(in: .named(Constants.coordinateSpaceName))
    let visibleFrame = CGRect(x: 0, y: 0, width: bounds.width, height: bounds.height)

    var frameIndices: [Int] = []
    for (index, rect) in rects {
        if rect.intersects(visibleFrame) {
            frameIndices.append(index)
        }
    }

    frameIndices.sort()

    let firstIndex = frameIndices.first ?? 0
    var lastIndex = frameIndices.last ?? 0

    if rects[lastIndex]?.maxX ?? 0 < visibleFrame.maxX, lastIndex < data.count - 1 {
        lastIndex += 1
    }

    visibleIndices = firstIndex...lastIndex
}

(пытливый критический взгляд может заметить, что количество visibleIndices не может быть меньше 1 и правильней было бы иметь это значение опциональным, но для простоты оставим как есть)


Протестируем, используя метод onAppear(), который вызывается при появлении элемента на экране:


CardView(index: index, title: self.items[index])
    .onAppear() {
        print("Appeared index: \(index)")
    }

Appeared index: 0
Appeared index: 1
Appeared index: 2

Отлично, как видно из лога после запуска появились на экран только 3 элемента.


Глава 4. Тянем-потянем


Следующим шагом развития нашего компонента будет поддержка Drag Gestures для скроллинга данных. Так как изменение позиции скролла влияет на отображаемые элементы, переменные offset и dragOffset будут State переменными.


@State private var offset: CGFloat = 0
@State private var dragOffset: CGFloat = 0

var contentOffset: CGFloat {
    return offset + dragOffset
}

Добавим к нашему компоненту DragGesture и его обработчики:


public var body: some View {
    GeometryReader { geometry in
        ZStack {
            ...
        }
        .gesture(
             DragGesture()
                .onChanged({ value in
                     // Scroll by dragging
                     self.dragOffset = -value.translation.width
                     self.updateVisibleIndices(geometry: geometry)
                 })
                 .onEnded({ value in
                      self.offset = self.offset + self.dragOffset
                      self.dragOffset = 0
                      self.updateVisibleIndices(geometry: geometry)
                }))
    }
}

В результате у нас появится вычисляемый contentOffset, который мы будем применять для калькуляции видимого фрейма и позиций элементов:


private func makeView(atIndex index: Int) -> some View {
    ...
    return content
              .offset(x: itemRect.minX - contentOffset)
}

private func updateVisibleIndices(geometry: GeometryProxy) {
    ...
    let visibleFrame = CGRect(x: contentOffset, y: 0, width: bounds.width, height: bounds.height)
    ...
}

Запускаем приложение:


enter image description here


Appeared index 0
Appeared index 1
Appeared index 2
Appeared index 3
Appeared index 4
Appeared index 5
Appeared index 6
Appeared index 7
Appeared index 8
Appeared index 9
Appeared index 10

Вот мы и реализовали основную логику для горизонтального скролла с большим количеством данных и загрузкой их по необходимости.


Глава 5. Крутите барабан


Текущая реализация скролла элементов с помощью drag gesture не учитывает velocity. Настало время улучшить логику скролла и исправить этот пробел. Для того, чтобы учесть скорость прокрутки нам необходима анимация.


Любая анимация строится на изменении значений за какой-то период времени. Для того чтобы фиксировать периоды времени нам необходим таймер:


@State private var animationTimer = Timer.publish (every: 1/60, on: .current, in: .common).autoconnect()

Теперь когда у нас есть таймер, надо знать, что анимировать. Список получается довольно простой: startPosition, endPosition и scrollDuration. Единственное, так как нам не надо перегружать View при изменении какого-либо из этих значений, мы их упакуем в модель класса:


class HorizontalListScrollAnimator {
    var isAnimationFinished: Bool = true

    private var startPosition: CGFloat = 0
    private var endPosition: CGFloat = 0
    private var scrollDuration: Double = 0
    private var startTime: TimeInterval = 0

    func start(from start: CGFloat, to end: CGFloat, duration: Double = 1.0) {
        startPosition = start
        endPosition = end
        scrollDuration = duration
        isAnimationFinished = false
        startTime = CACurrentMediaTime()
    }

    func stop() {
        startPosition = 0
        endPosition = 0
        scrollDuration = 0
        isAnimationFinished = true
        startTime = 0
    }

    func nextStep() -> CGFloat {
        let currentTime = CACurrentMediaTime()

        let time = TimeInterval(min(1.0, (currentTime - startTime) / scrollDuration))

        if time >= 1.0 {
            isAnimationFinished = true
            return endPosition
        }

        let delta = easeOut(time: time)
        let scrollOffset = startPosition + (endPosition - startPosition) * CGFloat(delta)

        return scrollOffset
    }

    private func easeOut(time: TimeInterval) -> TimeInterval {
        return 1 - pow((1 - time), 4)
    }
}

И завершающим шагом интегрируем модель анимации с таймером в наше View:


private var scrollAnimator = HorizontalListScrollAnimator()
public var body: some View {
        GeometryReader { geometry in
            ...
        }
        .gesture(
            DragGesture()
                ...
                .onEnded({ value in
                    let predictedWidth = value.predictedEndTranslation.width * 0.75
                    if abs(predictedWidth) - abs(self.dragOffset) > geometry.size.width / 2 {
                        // Scroll with animation to predicted offset
                    self.dragOffset = 0

                  self.scrollAnimator.start(from: self.offset, to: (self.offset - predictedWidth), duration: 2)
                        self.animationTimer = Timer.publish (every: 1/60, on: .current, in:.common).autoconnect()
                    } else {
                        // Save dragging offset
                        self.offset = self.offset + self.dragOffset
                         self.dragOffset = 0

                     self.updateVisibleIndices(geometry: geometry)
         .gesture(
             TapGesture()
                 .onEnded({ _ in
                      // Stop scroll animation on tap
                            self.scrollAnimator.stop()
                      self.animationTimer.upstream.connect().cancel()
                    }))
            }))
            .onReceive(self.animationTimer) { _ in
             if self.scrollAnimator.isAnimationFinished {
                 // We don't need it when we start off
                 self.animationTimer.upstream.connect().cancel()
                 return
             }

             self.offset = self.scrollAnimator.nextStep()

                self.updateVisibleIndices(geometry: geometry)
            }
        }
    }

Глава 6. Граница на замке


В нашем рабочем компоненте есть одна проблема — можно спокойно проскроллить туда где нет данных. Поэтому следует добавить логику, которая будет проверять не вышли ли мы за границы. Реализуем метод safeOffset, возвращающий безопасные значения для отступа.


func safeOffset(x: CGFloat) -> CGFloat {
    return x.clamped(to: 0...(maxOffset ?? CGFloat.greatestFiniteMagnitude))
}

У CGFloat нет метода clamped, но его можно легко добавить с помощью расширения:


extension Comparable {
    func clamped(to limits: ClosedRange<Self>) -> Self {
        return min(max(self, limits.lowerBound), limits.upperBound)
    }
}

Ниже представлен полный код с получением возможного максимального отступа и использованием метода safeOffset:


...

@State private var maxOffset: CGFloat?

var contentOffset: CGFloat {
    return safeOffset(x: offset + dragOffset)
}

...

public var body: some View {
        GeometryReader { geometry in
            ...
        }
        .gesture(
            DragGesture()
                ...
                .onEnded({ value in
                    ...
                    self.offset = self.safeOffset(x: self.offset + self.dragOffset)
                     ...
                 }))
                 ...
                 .onPreferenceChange(ViewRectPreferenceKey.self) { preferences in
                      // Update subviews rects
                      for preference in preferences {
                          ...
                          // Update max valid offset if needed
                          if self.maxOffset == nil, let lastRect = self.rects[self.data.count - 1] {
                              self.maxOffset = max(0, lastRect.maxX - geometry.frame(in: .global).width)
                          }
                      }
                      ...
                   }
                   .onReceive(self.animationTimer) { _ in
                        ....
                        self.offset = self.scrollAnimator.nextStep()

                        // Check if out of bounds
                        let safeOffset = self.safeOffset(x: self.offset)
                        if self.offset != safeOffset {
                        self.offset = safeOffset
                        self.dragOffset = 0
                        ...
                    }
}

Глава 7. Кэш


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


class HorizontalListModel<Content> where Content : View {
    var cachedContent: [Int: Content] = [:]

    init() {
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(clearCacheData),
                                               name: UIApplication.didReceiveMemoryWarningNotification,
                                               object: nil)
    }

    @objc func clearCacheData() {
        cachedContent.removeAll()
    }
}

...
private let model = HorizontalListModel<Content>()
...

    private func makeView(atIndex index: Int) -> some View {
        ...
        var content = model.cachedContent[index]
        if content == nil {
            content = itemContent(item)
            model.cachedContent[index] = content
        }

        return content
                ...
    }

enter image description here


Послесловие.


Готовый компонент вы можете найти по адресу
Предыдущая наша статья на тему SwiftUI доступна здесь


Статья написана моим коллегой Денисом Шалагиным и опубликована для сообщества по его просьбе.


Всем счастливого WWDC2020!

Теги:
Хабы:
Всего голосов 6: ↑6 и ↓0+6
Комментарии1

Публикации

Истории

Работа

iOS разработчик
22 вакансии
Swift разработчик
24 вакансии

Ближайшие события

Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
OTUS CONF: GameDev
Дата30 мая
Время19:00 – 20:30
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область