Developing an Infinite Four-Direction Scrollable Pager with SwiftUI

Published on

Get weekly handpicked updates on Swift and SwiftUI!

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:

  1. 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.

  2. 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.

image-20240710095631674

  • Handling of Limited Scrolling Areas: When the scrolling area is limited (such as a 5x5 size), dynamically draw visible views based on the limitations.

image-20240710101036522

  • 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.
  • 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 and HStack is a simple and effective method.
  • Another method is to use multiple overlays with alignment guides, for example:
Swift
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:
Swift
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:

  1. Even if the onEnded closure isn’t called when the system interrupts the drag gesture, SwiftUI automatically resets the value of isDragging.

  2. By observing changes in isDragging and determining whether the onEnded closure has been called, we can fully grasp the gesture’s state.

  3. 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 the disable modifier.
  • It’s important to note that the disable modifier can simultaneously block interactions for both onTapGesture and Button, while allowsHitTesting only blocks onTapGesture, which may still result in accidental Button 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:

  1. Animation Optimization: The current implementation uses an easeOut animation effect to reduce the likelihood of gaps appearing.

  2. 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.

  3. 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.

  4. 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:

Swift
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 the mainPage value changes.
  • We compare the row and column information (h and v) 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.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed