Русский
Русский
English
Статистика
Реклама

HorizontalList с помощью SwiftUI

Вступление.


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: 0Appeared index: 1Appeared index: 2

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


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


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


@State private var offset: CGFloat = 0@State private var dragOffset: CGFloat = 0var 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 0Appeared index 1Appeared index 2Appeared index 3Appeared index 4Appeared index 5Appeared index 6Appeared index 7Appeared index 8Appeared index 9Appeared 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


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


Готовый компонент вы можете найти по адресу: https://github.com/DistilleryTech/HorizontalList
Предыдущая наша статья на тему SwiftUI доступна здесь: http://personeltest.ru/aways/habr.com/ru/post/501790/


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


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

Источник: habr.com
К списку статей
Опубликовано: 21.06.2020 10:10:12
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Swift

Разработка под ios

Swiftui

Ios

Ios development

Категории

Последние комментарии

© 2006-2020, personeltest.ru