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:
- Native API in iOS 17+
- UIKit integration via
introspect
- 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.
If you enjoyed this article, please feel free to follow me on my social media: