Разработка
Создаем замену List в SwiftUI
Замена List в SwiftUI — это не отказ от мощного компонента, а выбор подходящего инструмента под конкретную задачу.
Когда в SwiftUI заходит речь о создании прокручиваемого экрана, первым делом обычно вспоминают List. Однако это далеко не всегда лучший выбор. List отлично подходит для отображения однотипных данных. Во всех остальных случаях ScrollView в связке с lazy stack почти всегда оказывается более удачным решением. На этой неделе разберём, как собрать собственный прокручиваемый контейнер в SwiftUI с точным контролем над внешним видом и поведением.
Сразу отмечу: за последние несколько лет SwiftUI заметно улучшил производительность ScrollView в паре с lazy stack. Поэтому, если вы не отображаете сотни тысяч однотипных элементов вроде почтовых ящиков или to-do списков, ScrollView — это именно тот путь, по которому стоит идти.
Выше показаны четыре скриншота. Первые два отражают текущее состояние моего приложения CardioBot. Следующие два — это результат, к которому я хочу прийти. Как можно заметить, сейчас я использую стандартный List, и мне по-прежнему нравится, как приложение выглядит и ощущается в работе. Но я решил пересмотреть свой UI. Хочется сохранить простоту и привычность для пользователей iPhone, но при этом сделать интерфейс чуть более выразительным.
Как видно, приложение показывает разные метрики здоровья. Это не единообразный набор данных, поэтому использовать List ради переиспользования ячеек здесь не особенно логично. У меня используется несколько типов карточек: HeroCard, TintedCard и RegularCard. Похожего визуального результата можно добиться и с помощью List, используя специфичные для него модификаторы вроде listRowBackground, listItemTint и listRowInsets. Проблема в том, что эти модификаторы работают только внутри List, а за его пределами приходится отдельно повторять стилизацию.
К счастью, в SwiftUI появились Container View API, которые позволяют построить замену List. Эти API дают возможность декомпозировать SwiftUI-представления, вносить изменения и затем заново их собирать. Благодаря этому можно создавать переиспользуемые контейнеры наподобие List, Form или вообще полностью кастомные компоненты.
public struct ScrollingSurface<Content: View>: View {
public enum Direction {
case vertical(HorizontalAlignment)
case horizontal(VerticalAlignment)
}
let direction: Direction
let spacing: CGFloat?
let content: Content
public init(
_ direction: Direction = .vertical(.leading),
spacing: CGFloat? = nil,
@ViewBuilder content: () -> Content
) {
self.spacing = spacing
self.direction = direction
self.content = content()
}
public var body: some View {
switch direction {
case .horizontal(let alignment):
ScrollView(.horizontal) {
LazyHStack(alignment: alignment, spacing: spacing) {
content
}
.scrollTargetLayout()
.padding()
}
case .vertical(let alignment):
ScrollView(.vertical) {
LazyVStack(alignment: alignment, spacing: spacing) {
content
}
.scrollTargetLayout()
.padding()
}
}
}
}
На каждом экране моего приложения используется ScrollView в связке с lazy stack. Поэтому я создал собственный тип ScrollingSurface. По сути, это простой обёрточный компонент над ScrollView и LazyVStack или LazyHStack — в зависимости от выбранного направления прокрутки. Я использую ScrollingSurface как корневой view для каждого экрана приложения.
public struct DividedCard<Content: View>: View {
let content: Content
public init(@ViewBuilder content: () -> Content) {
self.content = content()
}
public var body: some View {
Group(subviews: content) { subviews in
if !subviews.isEmpty {
VStack(alignment: .leading) {
ForEach(subviews) { subview in
subview
if subviews.last?.id != subview.id {
Divider()
.padding(.vertical, 8)
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 32))
}
}
}
}
Следующий важный примитив пользовательского интерфейса — это тип DividedCard. Как видно, он использует Group(subviews:), который является частью Container View API в SwiftUI. Этот инициализатор Group позволяет декомпозировать представление, переданное через замыкание ViewBuilder.
Чтобы подробнее изучить Container View API в SwiftUI, ознакомьтесь с моей отдельной статьёй «Осваиваем контейнерные представления в SwiftUI. Основы».
В DividedCard мы разбиваем переданное представление на дочерние элементы и добавляем разделитель после каждого из них. В конце оборачиваем всё содержимое в фон со скруглёнными углами, чтобы придать ему вид карточки.
public struct SectionedSurface<Content: View>: View {
let content: Content
public init(@ViewBuilder content: () -> Content) {
self.content = content()
}
public var body: some View {
ForEach(sections: content) { section in
if !section.content.isEmpty {
section.header.padding(.top)
section.content
section.footer
}
}
}
}
Ещё один интересный UI-примитив, который я реализовал — это SectionedSurface. Он использует ForEach(sections:), что позволяет извлекать все секции из переданного представления, отфильтровывать пустые секции и добавлять отступы для заголовков секций.
public struct NavigationButtonStyle: ButtonStyle {
public func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.label
.opacity(configuration.isPressed ? 0.7 : 1)
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
.contentShape(.rect)
}
}
extension ButtonStyle where Self == NavigationButtonStyle {
public static var navigation: Self { .init() }
}
Одна из вещей, которой может не хватать при использовании List-представления в SwiftUI, — это стилизация NavigationLink. List автоматически добавляет стрелку на крае NavigationLink. К счастью, этого можно добиться с помощью кастомного стиля кнопки.
public struct SummaryView: View {
let summary: SummaryStore
public var body: some View {
ScrollingSurface {
SectionedSurface {
coachSection
activitySection
recoverySection
vitalsSection
heartRateSection
alcoholicBeveragesSection
}
}
.buttonStyle(.navigation)
}
@ViewBuilder private var activitySection: some View {
Section {
if !summary.metrics.workouts.isEmpty {
DividedCard {
ForEach(summary.metrics.workouts, id: \.workout.uuid) { snapshot in
NavigationLink {
WorkoutDetailsView(snapshot: snapshot)
} label: {
WorkoutView(snapshot: snapshot)
}
}
}
}
} header: {
SectionHeader(
.horizontal,
title: Text("activitySection"),
systemImage: "figure.run"
)
.tint(.orange)
}
}
}
Вот пример кода из моего приложения, демонстрирующий использование новых UI-примитивов, которые мы реализовали ранее. Как видно, API получился очень похожим на List, но при этом у нас есть полный контроль над внешним видом и поведением. Это позволяет переиспользовать эти примитивы даже на экранах без секций — достаточно просто убрать SectionedSurface.
Замена List в SwiftUI — это не отказ от мощного компонента, а выбор подходящего инструмента под конкретную задачу. List по-прежнему отлично подходит для больших и однотипных наборов данных, но современный SwiftUI даёт гибкость для построения более кастомных решений, когда этого требует интерфейс.
Используя ScrollView в сочетании с lazy stack и Container View API, можно не только воспроизвести возможности List, но и в ряде случаев превзойти их. Такие кастомные примитивы, как ScrollingSurface, DividedCard и SectionedSurface, показывают, как можно собирать переиспользуемые строительные блоки, соответствующие дизайн-языку продукта, при этом сохраняя производительность и читаемость кода.
Надеюсь, статья была полезной. Можете подписаться на меня в Twitter и задать любые вопросы по теме. Спасибо за чтение и до встречи на следующей неделе!
-
Новости4 недели назадВидео и подкасты о мобильной разработке 2026.11
-
Новости2 недели назадВидео и подкасты о мобильной разработке 2026.13
-
Новости3 недели назадВидео и подкасты о мобильной разработке 2026.12
-
Разработка2 недели назад10 ошибок, которые Android-разработчики до сих пор допускают при работе с Jetpack Compose
