Користувацькі ViewModifier у SwiftUI: патерни багаторазового використання для design system
Створюйте користувацькі ViewModifier у SwiftUI для узгодженого design system. Патерни, кращі практики та практичні приклади ефективного стилізування iOS view.

ViewModifier формують основу надійного та підтримуваного design system у SwiftUI. Інкапсулюючи стилі та поведінку у компонентах багаторазового використання, вони забезпечують візуальну узгодженість і кардинально зменшують дублювання коду. Цей модульний підхід трансформує спосіб побудови сучасних iOS-інтерфейсів.
Ця стаття представляє ключові патерни створення ефективних користувацьких ViewModifier — від базової структури до складних композицій для повноцінного design system.
Анатомія ViewModifier
ViewModifier — це протокол, що визначає трансформацію, яку можна застосувати до будь-якого view. На відміну від розширень View, ViewModifier можуть зберігати внутрішній стан і приймати конфігуровані параметри.
import SwiftUI
// Basic structure of a custom ViewModifier
struct CardModifier: ViewModifier {
// Configurable parameters
var cornerRadius: CGFloat = 12
var shadowRadius: CGFloat = 4
// The body method applies transformations
func body(content: Content) -> some View {
content
.padding()
.background(Color(.systemBackground))
.cornerRadius(cornerRadius)
.shadow(
color: .black.opacity(0.1),
radius: shadowRadius,
x: 0,
y: 2
)
}
}
// Extension for fluent syntax
extension View {
func cardStyle(
cornerRadius: CGFloat = 12,
shadowRadius: CGFloat = 4
) -> some View {
modifier(CardModifier(
cornerRadius: cornerRadius,
shadowRadius: shadowRadius
))
}
}Патерн Content представляє view, до якого застосовується modifier. Ця абстракція дозволяє одному й тому ж modifier працювати з будь-яким типом SwiftUI view.
Використання CardModifier на практиці
Після створення застосування modifier стає інтуїтивним і природно читається у коді:
struct ProductCard: View {
let product: Product
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Product image
AsyncImage(url: product.imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(height: 150)
.clipped()
// Product information
Text(product.name)
.font(.headline)
Text(product.price, format: .currency(code: "USD"))
.foregroundStyle(.secondary)
}
.cardStyle() // Applying the modifier
}
}
struct Product: Identifiable {
let id: UUID
let name: String
let price: Decimal
let imageURL: URL?
}Ясність коду суттєво покращує підтримуваність. Зміна стилю всіх карток у застосунку вимагає лише однієї зміни у ViewModifier.
Умовні modifier з @ViewBuilder
ViewModifier можуть містити умовну логіку для адаптації поведінки до контексту. Атрибут @ViewBuilder дозволяє будувати складні view з розгалуженнями.
struct BadgeModifier: ViewModifier {
let count: Int
let color: Color
let showZero: Bool
init(count: Int, color: Color = .red, showZero: Bool = false) {
self.count = count
self.color = color
self.showZero = showZero
}
// @ViewBuilder enables conditions in the body
@ViewBuilder
func body(content: Content) -> some View {
if count > 0 || showZero {
content.overlay(alignment: .topTrailing) {
badgeView
}
} else {
// Returns the view without modification
content
}
}
// Badge view extracted for readability
private var badgeView: some View {
Text(count > 99 ? "99+" : "\(count)")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color)
.clipShape(Capsule())
.offset(x: 8, y: -8)
}
}
extension View {
func badge(
count: Int,
color: Color = .red,
showZero: Bool = false
) -> some View {
modifier(BadgeModifier(
count: count,
color: color,
showZero: showZero
))
}
}Цей патерн дозволяє додавати бейджі сповіщень до будь-якого елемента інтерфейсу декларативно та з можливістю конфігурації.
SwiftUI автоматично оптимізує гілки @ViewBuilder. Невідображене view не рендериться, що зберігає продуктивність навіть при складних умовах.
Design system з токенами стилів
Зрілий design system спирається на токени — семантичні константи, які визначають візуальну ідентичність застосунку. ViewModifier ідеально інкапсулюють ці токени.
import SwiftUI
// Spacing tokens
enum Spacing {
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 16
static let lg: CGFloat = 24
static let xl: CGFloat = 32
}
// Radius tokens
enum Radius {
static let sm: CGFloat = 4
static let md: CGFloat = 8
static let lg: CGFloat = 16
static let full: CGFloat = 9999
}
// Typography tokens
enum Typography {
static let displayLarge = Font.system(size: 34, weight: .bold)
static let displayMedium = Font.system(size: 28, weight: .bold)
static let titleLarge = Font.system(size: 22, weight: .semibold)
static let titleMedium = Font.system(size: 17, weight: .semibold)
static let bodyLarge = Font.system(size: 17, weight: .regular)
static let bodyMedium = Font.system(size: 15, weight: .regular)
static let caption = Font.system(size: 12, weight: .regular)
}
// Semantic color tokens
enum SemanticColor {
static let primary = Color("PrimaryColor")
static let secondary = Color("SecondaryColor")
static let success = Color.green
static let warning = Color.orange
static let error = Color.red
static let surface = Color(.systemBackground)
static let surfaceVariant = Color(.secondarySystemBackground)
}ViewModifier на основі токенів
Ці токени природно інтегруються у ViewModifier, що визначають стандартні стилі застосунку:
// Modifier for page titles
struct PageTitleModifier: ViewModifier {
func body(content: Content) -> some View {
content
.font(Typography.displayMedium)
.foregroundStyle(Color.primary)
}
}
// Modifier for section titles
struct SectionTitleModifier: ViewModifier {
func body(content: Content) -> some View {
content
.font(Typography.titleLarge)
.foregroundStyle(Color.primary)
}
}
// Modifier for secondary text
struct SecondaryTextModifier: ViewModifier {
func body(content: Content) -> some View {
content
.font(Typography.bodyMedium)
.foregroundStyle(.secondary)
}
}
// Fluent extensions for all text styles
extension View {
func pageTitle() -> some View {
modifier(PageTitleModifier())
}
func sectionTitle() -> some View {
modifier(SectionTitleModifier())
}
func secondaryText() -> some View {
modifier(SecondaryTextModifier())
}
}Використання стає виразним і самодокументованим, завдяки чому код залишається читабельним навіть для нових членів команди.
Готовий до співбесід з iOS?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
Інтерактивні modifier зі станом
ViewModifier можуть керувати власним станом для створення складної інтерактивної поведінки. Цей патерн виявляється потужним для анімацій та зворотного зв'язку з користувачем.
struct PressableModifier: ViewModifier {
// Internal modifier state
@State private var isPressed = false
// Configuration
let scale: CGFloat
let opacity: CGFloat
let animation: Animation
init(
scale: CGFloat = 0.95,
opacity: CGFloat = 0.8,
animation: Animation = .easeInOut(duration: 0.1)
) {
self.scale = scale
self.opacity = opacity
self.animation = animation
}
func body(content: Content) -> some View {
content
.scaleEffect(isPressed ? scale : 1.0)
.opacity(isPressed ? opacity : 1.0)
.animation(animation, value: isPressed)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
if !isPressed {
isPressed = true
}
}
.onEnded { _ in
isPressed = false
}
)
}
}
extension View {
func pressable(
scale: CGFloat = 0.95,
opacity: CGFloat = 0.8
) -> some View {
modifier(PressableModifier(scale: scale, opacity: opacity))
}
}Анімація завантаження з ефектом shimmer
Ще один приклад внутрішнього стану — ефект shimmer для placeholder-ів завантаження:
struct ShimmerModifier: ViewModifier {
// Continuous animation
@State private var phase: CGFloat = 0
let duration: Double
let bounce: Bool
init(duration: Double = 1.5, bounce: Bool = false) {
self.duration = duration
self.bounce = bounce
}
func body(content: Content) -> some View {
content
.overlay(
shimmerOverlay
.mask(content)
)
.onAppear {
withAnimation(
.linear(duration: duration)
.repeatForever(autoreverses: bounce)
) {
phase = 1
}
}
}
private var shimmerOverlay: some View {
GeometryReader { geometry in
LinearGradient(
colors: [
.clear,
.white.opacity(0.5),
.clear
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: geometry.size.width * 2)
.offset(x: -geometry.size.width + (geometry.size.width * 2 * phase))
}
}
}
extension View {
func shimmer(duration: Double = 1.5) -> some View {
modifier(ShimmerModifier(duration: duration))
}
}
// Usage for loading skeletons
struct SkeletonCard: View {
var body: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
RoundedRectangle(cornerRadius: Radius.md)
.fill(Color.gray.opacity(0.3))
.frame(height: 120)
RoundedRectangle(cornerRadius: Radius.sm)
.fill(Color.gray.opacity(0.3))
.frame(height: 20)
RoundedRectangle(cornerRadius: Radius.sm)
.fill(Color.gray.opacity(0.3))
.frame(width: 150, height: 16)
}
.shimmer()
}
}Композиція ViewModifier
Справжня сила ViewModifier полягає у їхній композиції. Кілька modifier можна поєднувати, щоб створювати складні стилі з простих будівельних блоків.
// Base modifier for primary buttons
struct PrimaryButtonStyleModifier: ViewModifier {
let isEnabled: Bool
func body(content: Content) -> some View {
content
.font(Typography.bodyLarge.weight(.semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.md)
.background(isEnabled ? SemanticColor.primary : Color.gray)
.cornerRadius(Radius.md)
}
}
// Modifier for secondary buttons (outline)
struct SecondaryButtonStyleModifier: ViewModifier {
let isEnabled: Bool
func body(content: Content) -> some View {
content
.font(Typography.bodyLarge.weight(.semibold))
.foregroundStyle(isEnabled ? SemanticColor.primary : .gray)
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.md)
.background(Color.clear)
.overlay(
RoundedRectangle(cornerRadius: Radius.md)
.stroke(
isEnabled ? SemanticColor.primary : Color.gray,
lineWidth: 2
)
)
}
}
// Extension combining style and interaction
extension View {
func primaryButton(isEnabled: Bool = true) -> some View {
self
.modifier(PrimaryButtonStyleModifier(isEnabled: isEnabled))
.pressable(scale: isEnabled ? 0.98 : 1.0)
.allowsHitTesting(isEnabled)
}
func secondaryButton(isEnabled: Bool = true) -> some View {
self
.modifier(SecondaryButtonStyleModifier(isEnabled: isEnabled))
.pressable(scale: isEnabled ? 0.98 : 1.0)
.allowsHitTesting(isEnabled)
}
}Така композиція дозволяє створювати варіації без дублювання коду. Кожен modifier залишається тестованим і модифікованим незалежно.
Порядок застосування modifier впливає на візуальний результат. padding перед background створює простір між контентом і фоном, тоді як зворотний порядок додає padding навколо фону.
Адаптивні modifier з Environment
ViewModifier можуть зчитувати значення оточення SwiftUI, щоб адаптуватися до контексту: темного режиму, доступного розміру тексту, орієнтації тощо.
struct AdaptiveCardModifier: ViewModifier {
// Reading environment values
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
func body(content: Content) -> some View {
content
.padding(adaptivePadding)
.background(backgroundColor)
.cornerRadius(Radius.lg)
.shadow(
color: shadowColor,
radius: shadowRadius,
x: 0,
y: shadowOffset
)
}
// Padding adapted to text size
private var adaptivePadding: CGFloat {
switch dynamicTypeSize {
case .xSmall, .small, .medium:
return Spacing.md
case .large, .xLarge:
return Spacing.lg
default:
return Spacing.xl
}
}
// Background color based on theme
private var backgroundColor: Color {
colorScheme == .dark
? Color(.secondarySystemBackground)
: Color(.systemBackground)
}
// More subtle shadow in dark mode
private var shadowColor: Color {
colorScheme == .dark
? .clear
: .black.opacity(0.1)
}
private var shadowRadius: CGFloat {
colorScheme == .dark ? 0 : 8
}
private var shadowOffset: CGFloat {
colorScheme == .dark ? 0 : 4
}
}
extension View {
func adaptiveCard() -> some View {
modifier(AdaptiveCardModifier())
}
}Цей modifier автоматично адаптується до системних налаштувань без додаткового коду у view, які його використовують.
Організація повноцінного design system
Структурований design system групує ViewModifier за функціональною категорією для зручності виявлення та підтримки:
// MARK: - Namespace for the Design System
enum DS {
// MARK: Text Styles
enum Text {
static func title(_ content: some View) -> some View {
content.modifier(PageTitleModifier())
}
static func section(_ content: some View) -> some View {
content.modifier(SectionTitleModifier())
}
static func body(_ content: some View) -> some View {
content.font(Typography.bodyLarge)
}
static func caption(_ content: some View) -> some View {
content.modifier(SecondaryTextModifier())
}
}
// MARK: Container Styles
enum Container {
static func card(_ content: some View) -> some View {
content.cardStyle()
}
static func adaptiveCard(_ content: some View) -> some View {
content.adaptiveCard()
}
}
// MARK: Button Styles
enum Button {
static func primary(_ content: some View, enabled: Bool = true) -> some View {
content.primaryButton(isEnabled: enabled)
}
static func secondary(_ content: some View, enabled: Bool = true) -> some View {
content.secondaryButton(isEnabled: enabled)
}
}
// MARK: Effects
enum Effect {
static func shimmer(_ content: some View) -> some View {
content.shimmer()
}
static func pressable(_ content: some View) -> some View {
content.pressable()
}
}
}Використання design system
Така організація створює виразний код, що документує власний намір:
struct ProfileScreen: View {
let user: User
@State private var isLoading = false
var body: some View {
ScrollView {
VStack(spacing: Spacing.lg) {
// Header with title style
Text("Profile")
.pageTitle()
// User card
UserCard(user: user)
.adaptiveCard()
// Settings section
VStack(alignment: .leading, spacing: Spacing.md) {
Text("Settings")
.sectionTitle()
SettingsRow(title: "Notifications")
SettingsRow(title: "Privacy")
SettingsRow(title: "Appearance")
}
.adaptiveCard()
// Logout button
Button("Log Out") {
// Action
}
.secondaryButton()
}
.padding(Spacing.md)
}
}
}
struct UserCard: View {
let user: User
var body: some View {
HStack(spacing: Spacing.md) {
AsyncImage(url: user.avatarURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.shimmer()
}
.frame(width: 60, height: 60)
.clipShape(Circle())
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(user.name)
.font(Typography.titleMedium)
Text(user.email)
.secondaryText()
}
Spacer()
}
}
}Тестування ViewModifier
ViewModifier можна тестувати незалежно, щоб гарантувати правильну поведінку:
import XCTest
import SwiftUI
final class ViewModifierTests: XCTestCase {
func testCardModifierAppliesCorrectCornerRadius() {
// Arrange
let modifier = CardModifier(cornerRadius: 20, shadowRadius: 4)
// Assert
XCTAssertEqual(modifier.cornerRadius, 20)
}
func testBadgeModifierHidesWhenCountIsZero() {
// Badge should not display if count = 0 and showZero = false
let modifier = BadgeModifier(count: 0, showZero: false)
// Verify behavior via snapshot tests or UI tests
}
func testPressableModifierInitialState() {
// Initial state should be not pressed
// Testable via ViewInspector or UI tests
}
}Бібліотека ViewInspector дозволяє інспектувати вміст SwiftUI view у юніт-тестах, спрощуючи валідацію ViewModifier.
Висновок
ViewModifier зарекомендовують себе як інструмент номер один для створення узгодженого та підтримуваного design system у SwiftUI. Їхня здатність інкапсулювати стилі, анімації та поведінку у компонентах багаторазового використання трансформує архітектуру сучасних iOS-застосунків.
Чек-лист SwiftUI design system
- ✅ Визначити токени дизайну (spacing, radius, typography, colors)
- ✅ Створити атомарні ViewModifier для кожного стилю
- ✅ Використовувати
@ViewBuilderдля умовних modifier - ✅ Керувати внутрішнім станом через
@Stateдля взаємодій - ✅ Композувати modifier для створення складних стилів
- ✅ Зчитувати environment для автоматичної адаптації
- ✅ Організувати modifier у чіткому namespace
- ✅ Надавати розширення View для плавного синтаксису
- ✅ Тестувати modifier незалежно
- ✅ Документувати використання конкретними прикладами
Починай практикувати!
Перевір свої знання з нашими симуляторами співбесід та технічними тестами.
Теги
Поділитися
Пов'язані статті

Продуктивність SwiftUI: Оптимізація LazyVStack та Складних Списків
Техніки оптимізації для LazyVStack та списків SwiftUI. Зменшення споживання пам'яті, покращення продуктивності прокручування та уникнення поширених помилок.

SwiftUI @Observable vs @State: Коли Що Використовувати у 2026
Опануйте відмінності між @Observable та @State у SwiftUI, щоб обрати правильний інструмент керування станом для застосунків iOS.

SwiftUI: створення сучасних інтерфейсів для iOS
Посібник зі створення сучасних інтерфейсів за допомогою SwiftUI: декларативний синтаксис, компоненти, анімації та найкращі практики для iOS 18.