Junior и Senior разработчики используют один и тот же язык, один и тот же фреймворк, но их код выглядит совершенно по-разному.
Дело не в опыте.
Дело в специфических методах, которые опытные разработчики накопили за годы работы над созданием и внедрением приложений в продашен.
После 4 лет разработки приложений на SwiftUI для клиентов я собрал 12 приемов, которые использую каждый день.
Некоторые из них — это малоизвестные функции API.
Некоторые — это архитектурные шаблоны.
Но все они значительно упростят ваш код.
Давайте начнем.
1. Замыкания @ViewBuilder для условной логики
Большинство разработчиков пишут следующее:
// ❌ Beginner approach — duplicates entire view structure
struct ProfileView: View {
let isPremium: Bool
var body: some View {
if isPremium {
VStack {
Text("Welcome back!")
.font(.title)
.foregroundStyle(.gold)
Image(systemName: "crown.fill")
.font(.largeTitle)
}
.padding()
.background(.ultraThinMaterial)
} else {
VStack {
Text("Welcome back!")
.font(.title)
.foregroundStyle(.primary)
}
.padding()
.background(.ultraThinMaterial)
}
}
}
Опытные разработчики выделяют условную часть:
// ✅ Senior approach — isolate variation
struct ProfileView: View {
let isPremium: Bool
var body: some View {
VStack {
Text("Welcome back!")
.font(.title)
.foregroundStyle(isPremium ? .yellow : .primary)
premiumBadge
}
.padding()
.background(.ultraThinMaterial)
}
@ViewBuilder
private var premiumBadge: some View {
if isPremium {
Image(systemName: "crown.fill")
.font(.largeTitle)
.foregroundStyle(.yellow)
}
}
}
@ViewBuilder позволяет вычисляемым свойствам возвращать some View с условными ветвлениями.
Устраняет дублирование представлений.
Сохраняет возможность быстрого просмотра содержимого body.
2. Кастомные ViewModifier для повторяющихся шаблонов стилей
Я постоянно это вижу:
// ❌ Repetitive styling scattered everywhere
Text("Title")
.font(.headline)
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.blue)
.clipShape(Capsule())
.shadow(color: .blue.opacity(0.3), radius: 8, y: 4)
Button("Action") { }
.font(.headline)
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.blue)
.clipShape(Capsule())
.shadow(color: .blue.opacity(0.3), radius: 8, y: 4)
Вынесите это в ViewModifier:
// ✅ Define once, use everywhere
struct PrimaryButtonStyle: ViewModifier {
var color: Color = .blue
func body(content: Content) -> some View {
content
.font(.headline)
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(color)
.clipShape(Capsule())
.shadow(color: color.opacity(0.3), radius: 8, y: 4)
}
}
extension View {
func primaryButton(color: Color = .blue) -> some View {
modifier(PrimaryButtonStyle(color: color))
}
}
// Usage — one line instead of seven
Text("Title").primaryButton()
Button("Action") { }.primaryButton(color: .green)
Одно изменение в PrimaryButtonStyle распространяется повсюду.
Так создаются дизайн-системы.
3. Используйте модификатор task вместо onAppear для асинхронной работы
Это одна из самых распространенных ошибок, которые я вижу:
// ❌ Don't do this — onAppear + Task manually
struct ArticleListView: View {
@State private var articles: [Article] = []
var body: some View {
List(articles) { article in
ArticleRow(article: article)
}
.onAppear {
Task {
articles = await ArticleService.fetchAll()
}
}
}
}
Вместо этого используйте .task:
// ✅ .task handles lifecycle automatically
struct ArticleListView: View {
@State private var articles: [Article] = []
var body: some View {
List(articles) { article in
ArticleRow(article: article)
}
.task {
articles = await ArticleService.fetchAll()
}
}
}
Ключевое отличие: метод .task автоматически отменяет асинхронную работу при исчезновении представления.
Если вы используете onAppear + Task, задача продолжает выполняться даже после исчезновения представления.
Фантомные обновления.
Плохо.
Для данных, которые обновляются при изменении значения:
// ✅ Re-runs task whenever selectedCategory changes
.task(id: selectedCategory) {
articles = await ArticleService.fetch(category: selectedCategory)
}
4. PreferenceKey для связи дочернего и родительского элементов
Данные SwiftUI передаются сверху вниз (родитель → дочерний элемент).
Но иногда дочернему элементу необходимо сообщить родительскому элементу что-то вроде своей высоты, положения или действия пользователя на глубоком уровне иерархии.
Большинство разработчиков используют замыкание колбека.
Это работает.
Но PreferenceKey — более удобный способ передачи данных макета:
// Define what data travels up
struct HeaderHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
// Child reports its height
struct HeaderView: View {
var body: some View {
Text("Header")
.padding()
.background(
GeometryReader { proxy in
Color.clear
.preference(key: HeaderHeightKey.self, value: proxy.size.height)
}
)
}
}
// Parent reads it
struct ContentView: View {
@State private var headerHeight: CGFloat = 0
var body: some View {
VStack {
HeaderView()
ScrollView {
// content offset by header height
}
.padding(.top, headerHeight)
}
.onPreferenceChange(HeaderHeightKey.self) { height in
headerHeight = height
}
}
}
Вот как внутри SwiftUI работают собственные элементы navigationTitle, toolbar и safeAreaInset.
5. Правильный GeometryReader (без нарушения компоновки)
GeometryReader — мощный инструмент, но он известен тем, что при неправильном использовании разрушает лейаут.
Опытные разработчики используют его в фоне, а не в качестве основного контейнера:
// ❌ Breaks layout — GeometryReader expands to fill available space
struct BadUsage: View {
var body: some View {
GeometryReader { proxy in
Text("Width: \(proxy.size.width)")
}
}
}
// ✅ Correct — reads geometry without affecting layout
struct GoodUsage: View {
@State private var containerWidth: CGFloat = 0
var body: some View {
Text("Width: \(containerWidth)")
.background(
GeometryReader { proxy in
Color.clear
.onAppear { containerWidth = proxy.size.width }
}
)
}
}
Схема: поместите GeometryReader в .background или .overlay с параметром Color.clear.
Он считывает размеры, не участвуя в компоновке.
6. matchedGeometryEffect для плавных переходов
Главная анимация между представлениями практически без кода:
struct HeroTransitionView: View {
@Namespace private var animation
@State private var isExpanded = false
var body: some View {
if isExpanded {
// Expanded card
VStack {
Image("photo")
.resizable()
.aspectRatio(contentMode: .fill)
.matchedGeometryEffect(id: "photo", in: animation)
.frame(height: 300)
.clipped()
Text("Full Article Title Here")
.matchedGeometryEffect(id: "title", in: animation)
.font(.title)
.padding()
Spacer()
}
.onTapGesture { withAnimation(.spring(duration: 0.5)) { isExpanded = false } }
} else {
// Collapsed card
HStack {
Image("photo")
.resizable()
.aspectRatio(contentMode: .fill)
.matchedGeometryEffect(id: "photo", in: animation)
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 12))
Text("Full Article Title Here")
.matchedGeometryEffect(id: "title", in: animation)
.font(.headline)
}
.padding()
.onTapGesture { withAnimation(.spring(duration: 0.5)) { isExpanded = true } }
}
}
}
@Namespace создает общее пространство для анимации.
Представления с одинаковым id в рамках одного пространства имен плавно анимируются между состояниями.
Переходы уровня App Store в 10 строках кода.
7. @FocusState для управления потоком ввода с клавиатуры
Поля форм без управления фокусом работают некорректно.
Пользователи ожидают, что порядок перехода по вкладкам, закрытие полей с помощью клавиатуры и навигация между полями будут работать без сбоев.
enum LoginField: Hashable { case email, password } struct LoginView: View { @State private var email = "" @State private var password = "" @FocusState private var focusedField: LoginField? var body: some View { VStack(spacing: 16) { TextField("Email", text: $email) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) .focused($focusedField, equals: .email) .onSubmit { focusedField = .password } // Move to next field on Return SecureField("Password", text: $password) .focused($focusedField, equals: .password) .onSubmit { login() } // Submit on Return Button("Sign In") { login() } } .padding() .onAppear { focusedField = .email } // Auto-focus first field .onTapGesture { focusedField = nil } // Dismiss keyboard on tap outside } func login() { focusedField = nil // perform login } }
Автоматическая фокусировка.
Порядок перехода по вкладкам.
Закрытие клавиатуры.
Интеллектуальное поведение клавиши Enter.
Все в одном перечислении + @FocusState.
8. LazyVStack vs VStack — знайте, какой для чего
Их неправильное применение может снижать производительность в длинных списках:
// ❌ VStack renders ALL 1000 items immediately
ScrollView {
VStack {
ForEach(0..<1000) { i in
ExpensiveRowView(index: i) // All 1000 initialized at once
}
}
}
// ✅ LazyVStack only renders visible items
ScrollView {
LazyVStack {
ForEach(0..<1000) { i in
ExpensiveRowView(index: i) // Only ~15 visible items at a time
}
}
}
Но LazyVStack не всегда лучше.
Для небольшого, простого контента (< 50 элементов) VStack быстрее, потому что избегает накладных расходов на ленивую инициализацию.
Правило:
- < 50 элементов, простые представления →
VStack - 50+ элементов или сложные построчные представления →
LazyVStack - Неизвестный размер данных / пагинация → всегда
LazyVStack
9. Протокол ButtonStyle для обратной связи при нажатии
Кнопки ощущаются более нативными за счет кастомных эффектов нажатия, учитывающих системные настройки специальных возможностей:
struct ScaleButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.94 : 1.0)
.opacity(configuration.isPressed ? 0.8 : 1.0)
.animation(.easeOut(duration: 0.1), value: configuration.isPressed)
}
}
struct BounceButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.9 : 1.0)
.animation(
configuration.isPressed
? .easeIn(duration: 0.1)
: .spring(response: 0.3, dampingFraction: 0.5),
value: configuration.isPressed
)
}
}
// Usage
Button("Save") { save() }
.buttonStyle(BounceButtonStyle())
Состояние configuration.isPressed автоматически управляется SwiftUI — оно корректно обрабатывает длительное нажатие, отмену перетаскивания и доступность.
Вам нужно лишь определить визуальный отклик.
10. redacted(reason:) для состояний загрузки без дополнительного кода
Большинство разработчиков создают собственные скелетные шаблоны.
В SwiftUI это уже реализовано:
struct ArticleCard: View {
let article: Article
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(article.title)
.font(.headline)
Text(article.author)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(article.preview)
.font(.body)
.lineLimit(3)
}
.padding()
}
}
// Show skeleton loading state — same view, no extra code
ArticleCard(article: .placeholder)
.redacted(reason: .placeholder)
Создайте статическое свойство .placeholder в вашей модели с фиктивными данными, затем примените .redacted(reason: .placeholder).
SwiftUI автоматически заменяет текст и изображения серыми мерцающими блоками.
Точно такой же вид, никакого шаблонного кода.
extension Article {
static let placeholder = Article(
title: "Article title placeholder text",
author: "Author Name",
preview: "This is a longer preview text that spans multiple lines to show the skeleton correctly."
)
}
11. Значения среды для корректного распространения зависимостей
Передача сервиса на 5 уровней вложенности через инициализаторы представления — это просачивание свойств (prop drilling).
Используйте среду:
// Define a custom environment key
struct ThemeKey: EnvironmentKey {
static let defaultValue = AppTheme.default
}
extension EnvironmentValues {
var appTheme: AppTheme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
// Inject at the root
@main
struct MyApp: App {
@State private var theme = AppTheme.dark
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.appTheme, theme)
}
}
}
// Access anywhere in the hierarchy — no passing required
struct DeepNestedView: View {
@Environment(\.appTheme) private var theme
var body: some View {
Text("Hello")
.foregroundStyle(theme.primaryColor)
}
}
Именно так SwiftUI передает значения colorScheme, font, locale и сотни других параметров.
Тот же механизм.
Доступно для ваших собственных данных.
12. onChange(of:) со старым и новым значениями (iOS 17+)
До iOS 17 onChange возвращал только новое значение.
Предыдущее значение приходилось сохранять вручную. В iOS 17 это исправлено:
// ❌ Old way — manual tracking required
struct SearchView: View {
@State private var query = ""
@State private var previousQuery = ""
var body: some View {
TextField("Search", text: $query)
.onChange(of: query) { newValue in
if previousQuery.isEmpty && !newValue.isEmpty {
// started typing
}
previousQuery = newValue
}
}
}
// ✅ iOS 17+ — both values provided
struct SearchView: View {
@State private var query = ""
var body: some View {
TextField("Search", text: $query)
.onChange(of: query) { oldValue, newValue in
if oldValue.isEmpty && !newValue.isEmpty {
// started typing — clean and clear
analytics.track("search_started")
}
if !oldValue.isEmpty && newValue.isEmpty {
// cleared search
analytics.track("search_cleared")
}
}
}
}
Сочетайте это с debounce для поиска по мере ввода текста:
.onChange(of: query) { _, newValue in
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await performSearch(query: newValue)
}
}
Закономерность всех 12 приемов
Рассматривая эти методы, можно выделить одну общую тему:
Опытные разработчики не пишут больше кода — они пишут меньше.
@ViewBuilderустраняет дублирование структур представленийViewModifierустраняет повторяющиеся стили.taskустраняет ручное управление жизненным цикломPreferenceKeyустраняет цепочки обратных колбеков.redactedустраняет код скелетных представлений
Цель не в том, чтобы писать умный код.
Цель — сокращение.
Каждая строка, которую вы не пишете, — это строка, которую вам не нужно отлаживать, тестировать или объяснять следующему разработчику.
Лучший код SwiftUI читается так, будто его написал человек, который действительно понимал, что он создает.
Что делать дальше
Эти приемы накапливаются.
Начните с тех, которые решают проблемы, которые у вас есть прямо сейчас:
- Пишете одну и ту же цепочку модификаторов несколько раз? → Прием №2
- Асинхронная загрузка данных вызывает проблемы с памятью? → Прием №3
- Формы кажутся неуклюжими? → Приём №7
- Списки работают медленно? → Приём №8
- Состояния загрузки выглядят некрасиво? → Приём №10
Выберите один вариант.
Переработайте один элемент интерфейса.
Посмотрите, как это повлияет на работу.

