The advent of SwiftUI has brought revolutionary changes to Apple ecosystem development, but it still faces some challenges when addressing certain complex requirements. Recently, I developed a component called Infinite4Pager, which supports infinite four-direction scrollable paging.
In this article, we will analyze the key ideas in the implementation process, discuss the points that need special attention, and candidly review the shortcomings of SwiftUI in coping with such scenarios. Through this case, we not only learn the specific technical implementation but also better understand how to break through the convention and solve problems creatively within the framework of SwiftUI.
Background
Recently, an interesting challenge has sparked lively discussions in my development community. A developer in my Discord channel proposed a unique requirement: to implement a four-direction scrollable pager component with SwiftUI. This component can be seen as an enhanced version of TabView
, supporting not only conventional left-right sliding but also up-down sliding. More challengingly, he wanted to implement lazy loading to optimize memory usage, especially when dealing with large numbers of image previews. Coincidentally, I also received a similar inquiry on Twitter recently, showing that this is indeed a common concern among developers.
The community responded quickly and passionately. Soon, an enthusiastic developer proposed a solution based on UIKit, using UICollectionView
and adding custom code in its gesture handling logic. However, when the discussion turned to the SwiftUI implementation, the general consensus was that it was fraught with difficulties, or even impossible to achieve.
When I noticed this discussion, the UIKit solution was already formed. While I agree that there are certain challenges in implementing this feature in SwiftUI, I believe it is not impossible. The key is to change the way of thinking, step out of the traditional implementation framework, and rethink the problem using SwiftUI’s unique declarative and reactive programming paradigms. This is precisely the charm of SwiftUI—it is not just a new framework, but also a new way of programming.
Below is a demonstration based on Infinite4Pager:
Challenges of SwiftUI
If we try to directly translate the implementation logic of UIKit to SwiftUI, we will face several major challenges:
Limited Customizability of ScrollView
Despite Apple’s significant enhancement of the control capabilities of ScrollView
in SwiftUI since WWDC 2023, and the introduction of more powerful scrolling control APIs at WWDC 2024, ScrollView
still has some core features that developers cannot customize. One of the most significant limitations is the intervention in its gesture behavior.
The gesture implementation of ScrollView
is built-in and fixed, and we cannot insert custom judgment logic into it. Even if we try to use parallel drag gestures to judge the direction at the beginning of scrolling, once we dynamically adjust the scrolling axis of ScrollView
, it will automatically readjust the alignment of the scrolling area. Although it is possible to correct the position using scrollPosition
, this may cause scrolling jitters or even interrupt the original drag gesture.
To learn more about the evolution of SwiftUI’s scroll control APIs, please refer to The Evolution of SwiftUI Scroll Control APIs and Highlights from WWDC 2024.
Inherent Limitations of Reactive Programming
The reactive features of SwiftUI are often its strength, but in some scenarios, they become a barrier to precise control.
For example, when dynamically adding data to the data source of a ForEach
to achieve infinite scrolling, it usually does not cause issues if data is appended after the existing data. However, if data is inserted at the beginning of the data source, it is likely to change the current scrolling position, appearing as a jump during scrolling. This situation is particularly evident when ScrollView
is used with a lazy container.
This is because when the data source changes, the lazy container needs to recalculate its height, involving the size calculations of all child views before the current position, thus causing the aforementioned issues.
For issues related to changes in the height of lazy containers, you can refer to List or LazyVStack: Choosing the Right Lazy Container in SwiftUI.
Lag in State Release
In SwiftUI, the memory management strategy of lazy containers for child views differs from developers’ usual understanding. It is not proactive enough in releasing memory resources, which may lead to excessive memory usage in scenarios such as browsing a large number of images, and even cause the application to crash.
To learn more about the memory release strategy of lazy containers, please check Tips and Considerations for Using Lazy Containers in SwiftUI.
These challenges highlight the need for different approaches and methods from UIKit when implementing complex custom components in SwiftUI. Next, we will explore how to cleverly use the features of SwiftUI to overcome these difficulties.
Solution
After analysis, we found that SwiftUI’s regular components like ScrollView
, lazy containers, and ForEach
might become obstacles rather than aids in this special requirement. Once we understood these limitations, our solution became clear and straightforward:
-
Custom Drag Gesture Simulation of Scrolling: This allows us to limit the scrolling direction through custom logic at the beginning of the drag, overcoming the limitations of
ScrollView
. -
Fixed View Quantity and Dynamic Content Updates: Abandoning the approach of lazy containers and
ForEach
, we chose to create a fixed number of views and dynamically update the content of the views based on position changes. This method maintains a smaller number of active views during the scrolling process, effectively solving the memory usage problem.
The specific implementation scheme is as follows:
- Flexible Scrolling Area Configuration: Allows users to customize the number of views in the scrolling area. Setting a direction to
nil
means infinite scrolling in that direction, and setting it to 0 disables scrolling in that direction. - Dynamic View Drawing: Based on the user-specified initial position, draw the current view and its surrounding up, down, left, and right views.
- Handling of Limited Scrolling Areas: When the scrolling area is limited (such as a 5x5 size), dynamically draw visible views based on the limitations.
- Intelligent Gesture Handling:
- During the drag start phase (
onChange
), determine and limit the scrolling direction based on the initial drag state, achieving smooth movement of the view. - At the end of the gesture (
onEnded
), decide whether to flip the page or bounce back based on the predicted position and set threshold.
- During the drag start phase (
- Seamless Loop Update: When a view completely scrolls out of the container, readjust the current view position and draw new surrounding views to achieve infinite scrolling.
This solution not only circumvents the limitations of SwiftUI but also fully utilizes its reactive features. Delightfully, once the basic scheme was determined, the core code implementation could be completed in just a few minutes.
Technical Highlights
In implementing this infinite four-direction scrollable pager component, we need to solve several key technical issues:
Multi-View Drawing and Container Layout
Our goal is to draw multiple views without affecting the layout size of the container. The solution is as follows:
- The container size should be only the size of a single view.
- Use the
overlay
modifier to draw a composite view containing the main view and surrounding views. - This method allows us to create views beyond the recommended size without affecting the layout of the main view.
- Apply the
clipped
modifier to hide the parts that exceed the container size.
To learn more about this technique, please refer to In-Depth Exploration of Overlay and Background Modifiers in SwiftUI.
Accurate Positioning of Multiple Views
For a grid-like view layout, we have several implementation methods:
- The combination of
VStack
andHStack
is a simple and effective method. - Another method is to use multiple
overlays
with alignment guides, for example:
overlay(alignment: .center) {
getPage(currentHorizontalPage, currentVerticalPage)
}
.overlay(alignment: .top) {
getAdjacentPage(direction: .vertical, offset: -1)
.alignmentGuide(.top) { $0[.bottom] }
}
.overlay(alignment: .bottom) {
getAdjacentPage(direction: .vertical, offset: 1)
.alignmentGuide(.bottom) { $0[.top] }
}
- Ultimately, I chose an implementation based on
Grid
.
These methods do not differ much in effect and performance, and can be chosen according to personal preference.
Determining the Completion of Scrolling
Determining whether a page scroll is complete is crucial for implementing seamless paging. Our method is:
- When the paging condition is met, use explicit animation to set the
offset
target position. - Use the animation completion callback mechanism introduced at WWDC 2023 to trigger the drawing of new views:
withAnimation(animation) {
offset = newOffset
} completion: {
// Perform the creation operation of new views
}
This method is convenient but increases the system version requirement. For compatibility with older versions, consider:
-
Using
GeometryReader
to monitor the position of the view. -
However, this method might trigger endpoint judgments multiple times when handling elastic animations.
Each method has its advantages and disadvantages, and the choice should be balanced against project requirements and the target user group.
Limitations and Considerations
Although the core functionality of this infinite four-direction scrollable pager component is relatively straightforward to implement, extra consideration and effort are still needed when handling edge cases. Here are some issues and solutions worth noting:
Handling System Interruption of Scroll Gestures
SwiftUI has a unique scenario: when a user touches the screen with one or more additional fingers during a drag operation, it may trigger a system gesture, interrupting the current drag gesture. This interruption doesn’t invoke the onEnded
closure, potentially causing the view to stall mid-scroll, affecting the user experience.
Solution:
We use the @GestureState
property wrapper to annotate the isDragging
state, which records the current drag status. The advantages of this approach are:
-
Even if the
onEnded
closure isn’t called when the system interrupts the drag gesture, SwiftUI automatically resets the value ofisDragging
. -
By observing changes in
isDragging
and determining whether theonEnded
closure has been called, we can fully grasp the gesture’s state. -
This method ensures that the view doesn’t remain stuck in the middle of the screen (i.e., interruption during the scrolling process).
Through this implementation, we can effectively handle cases where the system interrupts scroll gestures, ensuring smooth and reliable scrolling interactions, thereby enhancing the user experience.
Please read Customizing Gestures in SwiftUI and Exploring SwiftUI Property Wrappers: @AppStorage, @SceneStorage, @FocusState, @GestureState and @ScaledMetric to learn more about
@GestureState
and custom gestures.
Accidental Activation of Subview Gestures During Dragging
During the initial stages of dragging, users may inadvertently touch interactive areas within subviews, potentially interfering with normal user operations or causing confusion.
Solution:
- Utilizing the
isDragging
state mentioned earlier, the container temporarily disables click responses in subviews during the dragging process by applying thedisable
modifier. - It’s important to note that the
disable
modifier can simultaneously block interactions for bothonTapGesture
andButton
, whileallowsHitTesting
only blocksonTapGesture
, which may still result in accidentalButton
activations. - Additionally, we provide an environment value called
infinite4pagerIsDragging
. Developers can use this state value in their custom views to implement more precise gesture control.
View Gap Issues Caused by Elastic Animations
When using strong elastic animations for page transitions, brief gaps may appear between the main view and adjacent views. This issue is not only evident in the middle of scrolling views but also occurs as subtle gaps between views during scrolling when the container includes numerous subviews (such as in calendar application scenarios).
Root Cause Analysis: This is likely due to SwiftUI’s inability to precisely synchronize the position information of each element when applying animation interpolation to composite views (main view + adjacent views).
Solutions:
-
Animation Optimization: The current implementation uses an
easeOut
animation effect to reduce the likelihood of gaps appearing. -
Synchronization Processing: Utilize
geometryGroup
to synchronize animations in subviews as much as possible (providing complete animation interpolation information). While this doesn’t completely solve the problem, it can significantly improve animation synchronization in scenarios with numerous subviews. -
Visual Optimization: For non-fullscreen container-sized views, setting the container’s background color to match the view can effectively reduce users’ perception of gaps between views.
-
Awareness of Technical Limitations: It’s worth noting that even when using
geometryGroup
, SwiftUI may still exhibit slight asynchrony in the positioning of composite view elements during rapid animations. This is an inherent limitation of the current SwiftUI version that developers need to be aware of.
Challenges of Rapid Continuous Paging
Compared to paging components based on scrolling containers, our implementation faces some limitations in rapid continuous paging.
Main obstacles:
- Lack of a mechanism to dynamically create adjacent views.
- Even if dynamic construction is implemented, SwiftUI’s reactive features may cause view flickering.
- To optimize memory usage, dynamically adding views on only one side of the scrolling direction increases the complexity of alignment in the container
overlay
.
These challenges highlight some limitations of SwiftUI in handling complex, highly customized UI components. Although these problems are not insurmountable, they do require more innovative thinking and clever programming skills. In future versions of SwiftUI, we hope to see more native support for such complex interaction requirements.
Timing of Data Loading
Since our implementation does not use lazy containers, we cannot determine which view is currently displayed (remaining in the center of the container) through onAppear
or onDisappear
like typical SwiftUI views. To address this issue, we provide an alternative: using environment values to judge the current page.
Developers can respond to the environment value pagerCurrentPage
in the view, and combine the row and column information of the current view for judgment. Here is a sample implementation:
struct PageView: View {
let h: Int
let v: Int
@Environment(\.pagerCurrentPage) var mainPage
@State var isCurrent = false
var body: some View {
Rectangle()
.foregroundStyle(.white)
.border(.blue, width: 3)
.overlay(
Text("\(h),\(v): \(isCurrent)")
)
.task(id: mainPage) {
if let mainPage {
if mainPage.horizontal == h, mainPage.vertical == v {
isCurrent = true
}
}
}
}
}
In this example:
- We use
@Environment(\.pagerCurrentPage)
to obtain information about the current main page. - Through the
task(id:)
modifier, we can perform update operations when themainPage
value changes. - We compare the row and column information (
h
andv
) of the current view with the main page information to determine if this view is the current displayed page.
Although the library’s onPageVisible
modifier can provide more detailed information (such as the visibility of the view in the container), we advise caution when using this feature (prefer using pagerCurrentPage
in most cases):
- Applicable Scenarios:
onPageVisible
is suitable for triggering data loading, logging, and other non-visual operations, and the flag for whether to load should not be saved within the view. - Considerations: It is not recommended to use
onPageVisible
to trigger animations within the view, as this may cause animation tearing, affecting the user experience.
Combining the use of the above two methods, we provide developers with sufficient flexibility to handle complex animation triggering and data loading logic while ensuring performance.
Conclusion
A deep understanding of SwiftUI’s limitations not only helps us avoid potential problems early on but also inspires us to find innovative solutions. It is through these challenges that we deepen our understanding of the framework and push its boundaries of application.
This article aims to explore how to fully utilize SwiftUI’s features and layout logic to build custom components. Although SwiftUI still has room for improvement in some aspects, by flexibly applying its reactive programming model and declarative syntax, we can quickly build solutions that are acceptable in most scenarios with minimal code. We hope this article will inspire more developers to continuously innovate in the world of SwiftUI and unlock the full potential of this powerful tool.