Paging with Peek: Three Ways to Implement Paginated Scroll in SwiftUI


Creating a horizontally paginated scroll view where adjacent views are partially visible (“peek effect”) is a common UI requirement — particularly in carousels or card-based interfaces. Starting with iOS 17, SwiftUI makes this significantly easier. But if your app needs to support earlier iOS versions, alternative approaches are still possible.

In this article, we’ll cover three techniques to achieve a paginated layout with a peek effect:

  1. Native API in iOS 17+
  2. UIKit integration via introspect
  3. Custom SwiftUI-based paging solution

1. Native Paging with .scrollTargetBehavior (iOS 17+)

With iOS 17, SwiftUI introduces the .scrollTargetBehavior(.viewAligned) modifier. Combined with scrollTargetLayout() and horizontal padding, it enables paging behavior out of the box while allowing adjacent views to peek.

struct ExampleScrollView: View {
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack {
                ForEach(0..<20) { _ in
                    RoundedRectangle(cornerRadius: 22)
                        .fill(.purple)
                        .frame(width: 320, height: 100)
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned)
        .safeAreaPadding(.horizontal, 40)
    }
}

2. Using isPagingEnabled via Introspect

If you need to support earlier iOS versions, one option is to reach into UIKit using the SwiftUI Introspect library. You can enable paging on the underlying UIScrollView and apply outer padding to simulate the peek effect.

extension View {
    func pagingEnabled(_ isPagingEnabled: Bool) -> some View {
        self.introspect(.scrollView, on: .iOS(.v14...)) { view in
            view.isPagingEnabled = isPagingEnabled
        }
    }
}

Then apply it in a horizontal ScrollView:

ScrollView(.horizontal) {
    HStack(spacing: 0) {
        ForEach(0..<10) { _ in
            RoundedRectangle(cornerRadius: 20)
                .fill(.blue)
                .frame(width: 320, height: 150)
                .padding(.horizontal, 8)
        }
    }
}
.pagingEnabled(true)

This approach provides decent control, though it depends on a third-party library and bridges out of SwiftUI.

3. Custom Paging with _VariadicView

For a fully SwiftUI-native solution that works on older iOS versions and gives more control, you can implement a custom component based on _VariadicView. This requires internal API knowledge, but allows precise control over scrolling behavior and paging logic.

Here’s a simplified structure of such a component:

import SwiftUI

struct ExampleScrollView: View {
    var body: some View {
        PaginatedScrollView(.horizontal, margin: 20, spacing: 4) {
            ForEach(0..<20) { index in
                RoundedRectangle(cornerRadius: 22)
                    .fill(.purple)
                    .frame(maxWidth: .infinity)
                    .frame(height: 100)
            }
        }
    }
}

struct PaginatedScrollView<Content: View>: View { 
    private let content: () -> Content
    private let direction: _PagingViewConfig.Direction
    private let margin: CGFloat
    private let spacing: CGFloat
    private var size: CGFloat? = nil
    private var constrainedDeceleration: Bool = true
    private var page: Binding<_VariadicView.Children.Index>? = nil
    
    init(
        _ direction: _PagingViewConfig.Direction,
        margin: CGFloat = .zero,
        spacing: CGFloat = .zero,
        @ViewBuilder _ content: @escaping () -> Content
    ) {
        self.direction = direction
        self.margin = margin
        self.spacing = spacing
        self.content = content
    }
    
    var body: some View {
        _VariadicView.Tree<UnaryViewRoot, Content>(
            UnaryViewRoot(
                config: .init(
                    direction: direction,
                    size: size,
                    margin: margin,
                    spacing: spacing,
                    constrainedDeceleration: constrainedDeceleration
                ),
                page: page
            ),
            content: content
        )
    }
}

extension PaginatedScrollView {
    func size(_ size: CGFloat) -> PaginatedScrollView {
        var view = self
        view.size = size
        return view
    }
    
    func constrainedDeceleration(_ constrainedDeceleration: Bool) -> PaginatedScrollView {
        var view = self
        view.constrainedDeceleration = constrainedDeceleration
        return view
    }
    
    func page(_ page: Binding<_VariadicView.Children.Index>) -> PaginatedScrollView {
        var view = self
        view.page = page
        return view
    }
}

private struct UnaryViewRoot: _VariadicView.UnaryViewRoot {
    let config: _PagingViewConfig
    let page: Binding<_VariadicView.Children.Index>?
    
    func body(children: _VariadicView.Children) -> some View {
        _PagingView(
            config: config,
            page: page,
            views: children
        )
    }
}

This approach can be more complex to implement and maintain, and it involves working with undocumented SwiftUI internals (_VariadicView).

Conclusion

If you’re targeting iOS 17 and above, the native .scrollTargetBehavior(.viewAligned) is the most straightforward and reliable solution. For earlier versions, enabling isPagingEnabled via UIKit introspection offers a quick fix. And for full control — at the cost of complexity — custom implementations using internal APIs remain an option.

Choose the one that best matches your project’s requirements and deployment target.