SwiftUI ScrollView: Vodič za nove API-je i paging u iOS 26

Naučite kako scrollTargetBehavior, scrollPosition, containerRelativeFrame i scrollTransition zamjenjuju stari ScrollViewReader i omogućuju moderne paging carousele u SwiftUI.

SwiftUI ScrollView Paging Vodič iOS 26

Ažurirano: 23. kolovoza 2026.

SwiftUI ScrollView u iOS 17, 18 i 26 dobio je niz novih deklarativnih modifikatora (scrollTargetBehavior, scrollTargetLayout, scrollPosition, containerRelativeFrame, scrollTransition i onScrollGeometryChange) koji zajedno zamjenjuju stari, često nepouzdan pristup s ScrollViewReader. U ovom vodiču pokazat ću vam kako te API-je koristiti da napravite pravi carousel s paging efektom, pratite trenutnu poziciju i sinkronizirate ju s modelom, i to bez ijedne UIKit zaobilaznice. Iskreno, radila sam refactor točno ovakvog ekrana u produkciji na Robinhoodu i podijelit ću stvarne zamke na koje sam naletjela.

  • scrollTargetBehavior(.paging) i .viewAligned omogućavaju snap-to-page ponašanje bez UIPageViewController integracije, dostupno od iOS 17.
  • scrollPosition(id:) pruža dvosmjerno vezanje trenutno vidljivog elementa i radi u kombinaciji sa scrollTargetLayout.
  • containerRelativeFrame mjeri veličinu roditeljskog scroll containera, što je ključno za precizne carousel kartice.
  • scrollTransition animira svaki element ovisno o njegovoj poziciji u viewportu (scale, opacity, rotation).
  • onScrollGeometryChange u iOS 18 zamjenjuje ručno računanje offseta preko GeometryReader i PreferenceKey.
  • Većina novih API-ja zahtijeva iOS 17 ili noviji; za paging na iOS 16 morate se osloniti na TabView s .page stilom.

Kako radi scrollTargetBehavior

Modifikator scrollTargetBehavior govori SwiftUI runtimeu kako ScrollView treba završiti gestu nakon što korisnik pusti prst. Hoće li se zaustaviti na trenutnom offsetu (default), snap-ati na najbližu stranicu (.paging) ili na najbliži element unutar scrollTargetLayout (.viewAligned). Do iOS 17 morali smo koristiti UIPageViewController preko UIViewControllerRepresentable, ručno pratiti brzinu geste i implementirati snap logiku. Sada je to jedan modifier. Ozbiljno, jedan.

Bitno je razumjeti da scrollTargetBehavior ne mijenja layout, on samo mijenja završno ponašanje deceleration animacije. Zato ga treba kombinirati sa scrollTargetLayout kada koristite .viewAligned: layout modifier označava koji LazyHStack ili LazyVStack sadrži elemente na koje se treba snap-ati. Ako izostavite scrollTargetLayout, .viewAligned jednostavno neće raditi jer runtime ne zna koje su ćelije "targets".

import SwiftUI

struct BasicPagingExample: View {
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 0) {
                ForEach(0..<10) { index in
                    Rectangle()
                        .fill(Color(hue: Double(index) / 10, saturation: 0.7, brightness: 0.9))
                        .frame(width: UIScreen.main.bounds.width, height: 400)
                        .overlay(Text("Stranica \(index + 1)").font(.largeTitle))
                }
            }
        }
        .scrollTargetBehavior(.paging)
    }
}

Kad radimo carousel s vidljivim susjednim karticama, znate, onaj klasičan Instagram/Netflix pattern, trebamo kombinirati tri stvari: LazyHStack unutar ScrollView, scrollTargetLayout() na tom stacku i scrollTargetBehavior(.viewAligned) na scroll viewu. Svaka kartica mora imati definiranu širinu koja je manja od širine containera, inače se ponašanje svede na obični scroll.

U praksi sam vidjela da developeri često rade grešku i stavljaju scrollTargetLayout na krivo mjesto, obično na ScrollView ili na pojedinačnu ćeliju. Modifier ide isključivo na container koji drži djecu (HStack, VStack, LazyHStack, LazyVStack, Grid). Runtime tada svako dijete tog containera tretira kao potencijalni target.

struct CarouselView: View {
    let items = (0..<15).map { "Kartica \($0 + 1)" }

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 16) {
                ForEach(items, id: \.self) { item in
                    CardView(title: item)
                        // 85% širine containera, susjedne kartice ostaju vidljive
                        .containerRelativeFrame(
                            .horizontal,
                            count: 1,
                            span: 1,
                            spacing: 16
                        )
                }
            }
            .scrollTargetLayout()
        }
        .contentMargins(.horizontal, 24, for: .scrollContent)
        .scrollTargetBehavior(.viewAligned)
    }
}

struct CardView: View {
    let title: String

    var body: some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(.tint.gradient)
            .frame(height: 260)
            .overlay(Text(title).font(.title).foregroundStyle(.white))
    }
}

Ključni modifier je contentMargins(.horizontal, 24, for: .scrollContent). On dodaje unutarnji padding samo sadržaju unutar scroll viewa, a ne cijelom viewu. Bez toga bi prva i zadnja kartica bile zalijepljene za rub ekrana kad su centrirane. Apple ovo koristi u System Settings i Music aplikacijama, pa se isplati pogledati tamo za inspiraciju.

Praćenje pozicije sa scrollPosition

Modifier scrollPosition(id:) daje vam Binding koji odgovara identifikatoru trenutno "aktivnog" (najviše vidljivog) elementa unutar scrollTargetLayout. Kad korisnik skrola, binding se ažurira; kad programski postavite novu vrijednost, scroll view animirano skače na taj element. Ovo je, po mome iskustvu, najkorisniji dio novog API-ja. Zamjenjuje ručno slanje notifikacija ili trikove sa PreferenceKey, koji su nam znali generirati baš gadne race conditione.

Jedna stvar koju morate zapamtiti: scrollPosition(id:) zahtijeva da elementi imaju stabilne identifikatore preko ForEach(_:id:) ili tipa koji implementira Identifiable. Ako koristite indekse iz 0..<count, binding će raditi, ali će se ID-jevi promijeniti kad se lista modificira. To je izvor jednog buga koji sam lovila tri dana. Uvijek koristite stabilan ID iz vašeg modela.

struct PositionTrackingView: View {
    @State private var scrolledID: Int?
    let items = Array(0..<20)

    var body: some View {
        VStack {
            Text("Trenutna stranica: \(scrolledID.map { $0 + 1 } ?? 0)")
                .font(.headline)
                .padding()

            ScrollView(.horizontal) {
                LazyHStack(spacing: 12) {
                    ForEach(items, id: \.self) { index in
                        CardView(title: "Stavka \(index)")
                            .containerRelativeFrame(.horizontal)
                            .id(index)
                    }
                }
                .scrollTargetLayout()
            }
            .scrollTargetBehavior(.viewAligned)
            .scrollPosition(id: $scrolledID)

            HStack {
                Button("Prethodna") {
                    guard let current = scrolledID, current > 0 else { return }
                    withAnimation {
                        scrolledID = current - 1
                    }
                }
                Button("Sljedeća") {
                    guard let current = scrolledID, current < items.count - 1 else { return }
                    withAnimation {
                        scrolledID = current + 1
                    }
                }
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

Precizno dimenzioniranje s containerRelativeFrame

Prije iOS 17, mjerenje veličine roditeljskog scroll viewa značilo je omotavanje u GeometryReader, čitanje size.width i računanje širine ćelije kao proxy.size.width * 0.85. Taj pristup je krhak jer GeometryReader uzima svu dostupnu visinu, što lomi layout. Modifier containerRelativeFrame rješava taj problem tako što se referencira izravno na najbliži scroll container.

API prima Axis.Set, opcionalne parametre count, span, spacing te closure koji vam daje izračunatu veličinu i može ju prilagoditi. Za jednostavan carousel jedne kartice po ekranu koristite containerRelativeFrame(.horizontal). Za grid s tri kartice u redu: containerRelativeFrame(.horizontal, count: 3, spacing: 8). Za jednu karticu koja zauzima dvije trećine ekrana koristite closure varijantu (kao dolje).

// Dvije trećine širine, kvadratna kartica
Rectangle()
    .containerRelativeFrame(.horizontal) { width, axis in
        width * 0.66
    }
    .aspectRatio(1, contentMode: .fit)

// Grid layout: 3 stupca s razmakom
LazyVGrid(columns: [GridItem(.flexible())], spacing: 16) {
    ForEach(items) { item in
        item.view
            .containerRelativeFrame(
                .horizontal,
                count: 3,
                spacing: 16
            )
    }
}

Za dublje razumijevanje SwiftUI layout sistema i kako se dimenzije propagiraju, preporučam pročitati vodič za SwiftUI NavigationStack koji pokriva srodne koncepte kao što su navigationDestination i coordinate spaces koje ScrollView i NavigationStack dijele.

Animacije s scrollTransition

Modifier scrollTransition primjenjuje efekt (rotation, scale, opacity, offset) na svaki element ovisno o njegovoj trenutnoj poziciji u viewportu. Closure prima dva argumenta: view koji se transformira i ScrollTransitionPhase koji je enum s tri stanja: .topLeading (element ulazi u viewport), .identity (element je unutar viewporta) i .bottomTrailing (element izlazi).

Najbolji use case za scrollTransition je efekt "hero" kartice koja je najveća u centru i sužava se prema rubovima. Ovaj efekt sam koristila u produkciji za feed dionica na Robinhoodu jer daje osjećaj dubine bez potrebe za custom animation logikom. Važno je: transition se automatski interpolira između faza, ne trebate ručno animirati.

ScrollView(.horizontal) {
    LazyHStack(spacing: 20) {
        ForEach(items) { item in
            CardView(title: item.title)
                .containerRelativeFrame(.horizontal, count: 1, spacing: 20)
                .scrollTransition(.animated) { content, phase in
                    content
                        .opacity(phase.isIdentity ? 1.0 : 0.5)
                        .scaleEffect(phase.isIdentity ? 1.0 : 0.85)
                        .blur(radius: phase.isIdentity ? 0 : 4)
                }
        }
    }
    .scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)

Postoje tri varijante konfiguracije: .animated (kontinuirana animacija tijekom scrolla), .interactive (prati brzinu geste) i .identity (default, bez transitiona). Za carousele preporučam .animated, a za vertikalne feed-ove .interactive jer se prirodnije uklapa u UX.

Nadzor scroll geometrije u iOS 18

U iOS 18 Apple je dodao onScrollGeometryChange(for:of:action:), modifier koji vam daje callback kad se bilo koji dio scroll geometrije promijeni. Ovo napokon zamjenjuje neispravni pattern s GeometryReader unutar ScrollView i PreferenceKey propagacijom, koji su generirali stotine ažuriranja po sekundi i bili teški za debug-anje.

Prvi generic parametar je tip vrijednosti koju čitate (CGFloat, CGPoint, CGRect, ili custom struct). Drugi je closure koji iz ScrollGeometry objekta izvlači vrijednost koju želite pratiti: contentOffset, visibleRect, contentSize, containerSize. Treći je action closure koji dobija staru i novu vrijednost, i pokreće se samo kad se vrijednost stvarno promijeni. Puno rjeđe nego stara PreferenceKey petlja, hvala Bogu.

struct ParallaxHeaderView: View {
    @State private var scrollOffset: CGFloat = 0

    var body: some View {
        ScrollView {
            VStack {
                Image("hero")
                    .resizable()
                    .scaledToFill()
                    .frame(height: 300)
                    .scaleEffect(1 + max(0, -scrollOffset / 500))
                    .offset(y: scrollOffset < 0 ? scrollOffset / 2 : 0)

                ForEach(0..<30) { i in
                    Text("Redak \(i)").padding()
                }
            }
        }
        .onScrollGeometryChange(for: CGFloat.self) { geometry in
            geometry.contentOffset.y
        } action: { oldValue, newValue in
            scrollOffset = newValue
        }
    }
}

Prilagođeni ScrollTargetBehavior

Kad .paging i .viewAligned nisu dovoljni, na primjer, ako želite snap na svaki peti element ili na dinamički izračunatu poziciju, možete implementirati vlastiti ScrollTargetBehavior. Protokol zahtijeva jednu metodu, updateTarget(_:context:), u kojoj mijenjate target.rect.origin na željenu poziciju.

U mojoj indie aplikaciji za trening koristila sam custom behavior koji snap-a na najbliži "set" u treningu (grupa od 5 vježbi). Bez custom implementacije, morala bih ručno računati offset preko delegate metoda iz UIKita. Sa SwiftUI API-jem cijela logika stane u 15 linija koda. Pravi win.

struct SnapToEveryFifthBehavior: ScrollTargetBehavior {
    let itemHeight: CGFloat = 100
    let snapInterval: Int = 5

    func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
        let itemsInInterval = CGFloat(snapInterval) * itemHeight
        let currentIndex = target.rect.origin.y / itemsInInterval
        let snappedIndex = currentIndex.rounded()
        target.rect.origin.y = snappedIndex * itemsInInterval
    }
}

// Korištenje
ScrollView {
    LazyVStack(spacing: 0) {
        ForEach(0..<100) { i in
            RowView(index: i).frame(height: 100)
        }
    }
}
.scrollTargetBehavior(SnapToEveryFifthBehavior())

ScrollViewReader vs scrollPosition

Do iOS 17 jedini način programskog scrolla bio je ScrollViewReader, koji je pružao proxy.scrollTo(id:anchor:) metodu. Taj pristup i dalje radi i podržan je, ali ima nekoliko nedostataka koje scrollPosition rješava. Evo usporedbe jedan-na-jedan.

KarakteristikaScrollViewReaderscrollPosition
Minimalni iOSiOS 14iOS 17
Programski scrollDa (scrollTo)Da (binding)
Čitanje trenutne pozicijeNeDa (dvosmjerni binding)
Radi s LazyStackomDjelomično (problematičan s velikim listama)Da, optimizirano
Anchor kontrolaDa (.top, .center...)Da (preko anchor:)
Integracija sa snap ponašanjemNeDa (sa scrollTargetLayout)

Za nove projekte preporučam scrollPosition. Ostavite ScrollViewReader samo ako morate podržavati iOS 16 ili starije. U kombinaciji sa SwiftUI Charts za data visualization ili s SensoryFeedback za haptic response na promjenu stranice, dobijate cjelovito moderno carousel iskustvo.

Podrška za starije verzije iOS-a

Većina novih ScrollView API-ja zahtijeva iOS 17 ili noviji. onScrollGeometryChange zahtijeva iOS 18. Ako morate podržavati starije verzije, imate tri opcije: TabView s .page stilom (radi od iOS 14 ali samo za full-width paging), UIPageViewController preko UIViewControllerRepresentable (fleksibilnije, ali zahtijeva UIKit kod), ili grananje s if #available.

U mojoj praksi, kad je minimum bio iOS 16, koristila sam TabView(.page) za jednostavne carousele i if #available(iOS 17, *) granu za nove API-je. Rezultat je nešto duplog koda, ali izbjegava UIKit ovisnost. Detaljnu službenu referencu za sve modifiere pogledajte u Apple developer dokumentaciji za ScrollView, a povijesni pregled promjena po verzijama u SwiftUI update logu.

struct AdaptiveCarousel: View {
    var body: some View {
        if #available(iOS 17, *) {
            modernCarousel
        } else {
            legacyTabViewCarousel
        }
    }

    @available(iOS 17, *)
    private var modernCarousel: some View {
        ScrollView(.horizontal) {
            LazyHStack {
                ForEach(items) { item in
                    CardView(item: item)
                        .containerRelativeFrame(.horizontal)
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned)
    }

    private var legacyTabViewCarousel: some View {
        TabView {
            ForEach(items) { item in
                CardView(item: item)
            }
        }
        .tabViewStyle(.page)
    }
}

Za dublju analizu razlike u renderiranju između deklarativnih i imperativnih pristupa layoutu, korisno je proučiti kako Observation framework i @Observable makro utječu na view update graph, jer scroll eventi mogu okinuti nepotrebne re-render cikluse ako ne pazite na granularnost. Vrijedi konzultirati i WWDC 2023 sesiju "Beyond scroll views" gdje su ovi API-ji originalno predstavljeni.

Često postavljana pitanja

Radi li scrollTargetBehavior na iOS 16?

Ne, scrollTargetBehavior zahtijeva iOS 17 ili noviji. Za paging na iOS 16 koristite TabView s .tabViewStyle(.page), koji radi od iOS 14, ali podržava samo full-width paging bez peek efekta.

Koja je razlika između scrollTargetLayout i scrollTargetBehavior?

scrollTargetLayout se stavlja na container (LazyHStack, LazyVStack) i označava koja djeca su valjani snap targets. scrollTargetBehavior se stavlja na ScrollView i govori kako se ponašati pri kraju geste. Za .viewAligned ponašanje trebate oba.

Kako pratiti poziciju scrolla bez laga?

U iOS 18+ koristite onScrollGeometryChange(for: CGFloat.self) jer se pokreće samo kad se vrijednost stvarno promijeni. U iOS 17 koristite scrollPosition(id:) koji vam daje samo ID trenutno vidljivog elementa umjesto svakog piksela offseta. Izbjegavajte GeometryReader unutar ScrollView jer generira previše ažuriranja.

Kako napraviti carousel s vidljivim susjednim karticama?

Kombinirajte LazyHStack sa containerRelativeFrame(.horizontal) koji definira širinu kartice manju od containera (npr. 85%), zatim dodajte scrollTargetLayout() na stack i scrollTargetBehavior(.viewAligned) na ScrollView. contentMargins(.horizontal, 24, for: .scrollContent) dodaje padding za centrirane rubne kartice.

Zamjenjuje li scrollPosition ScrollViewReader?

Za većinu slučajeva u iOS 17+, da. scrollPosition daje dvosmjerni binding koji istovremeno čita trenutnu poziciju i omogućuje programsko postavljanje, što ScrollViewReader ne može. Zadržite ScrollViewReader samo ako podržavate iOS 16 ili starije.

O Autoru Mei-Lin Chen

Mei-Lin joined Robinhood in 2020 as an iOS engineer on the Crypto team and stayed through the SwiftUI rewrite of the order-entry flow before leaving in 2025. She also did a two-year stint at Asana earlier in her career working on the iPad app and the Mac Catalyst port. She writes about the parts of Apple's frameworks that the WWDC talks gloss over - what Observable actually does to your view-update graph, why @Bindable bindings tear in some animation contexts, and the surprisingly deep rabbit hole of Swift macros for boilerplate elimination. She has shipped two indie apps to the App Store, one of which hit #4 in the Health & Fitness category for a week in 2023. Mei-Lin is based in Seattle and has been writing Swift for 8 years.