Вступление
SwiftUI — это современный UI framework, который позволяет разработчикам быстро и легко создавать собственные приложения на всех платформах Apple.
Используя простой, понятный декларативный стиль, разработчики могут создавать потрясающие пользовательские интерфейсы с плавной анимацией. SwiftUI экономит время разработчиков, предоставляя огромное количество готовых решений, включая Interface Layout, Dark Mode, Accessibility, интернационализацию и многое другое. Приложения SwiftUI работают нативно и невероятно быстро. А поскольку SwiftUI — это один и тот же API, встроенный в iOS, iPadOS, macOS, watchOS и tvOS, разработчики могут быстрее и проще создавать отличные нативные приложения для всех платформ Apple.
Звучит amazing, не правда ли?
Введение
SwiftUI был анонсирован на WWDC2019 и за последний год было написано множество статей, посвященных этому фреймворку. Поэтому в данной статье мы не будем заострять внимание на таких вещах, как
- почему View это структура, а не класс
- что такое @State
- что такое Function Builders и кто такой @ViewBuilder
а сразу перейдем к практике и сделаем достаточно стандартную в повседневной жизни задачу — создание горизонтального списка.
Будет очень много кода и мало комментариев, впрочем, все как мы любим.
Глава 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 — все нативно и невероятно быстро.
Но есть одна проблема — если количество данных увеличится, то мы столкнемся с проблемой.
...
struct Constants {
static var itemsCount = 1000
...
}
...
Запускаем и ...
Глава 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)
}
}
Запускаем и (через какое-то время..) видим, что карточки успешно загрузились и отобразились:
Глава 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)
...
}
Запускаем приложение:
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
...
}
Послесловие.
Готовый компонент вы можете найти по адресу
Предыдущая наша статья на тему SwiftUI доступна здесь
Статья написана моим коллегой Денисом Шалагиным и опубликована для сообщества по его просьбе.
Всем счастливого WWDC2020!