Creating a Dynamic Draggable ScrollView with UIKit for SwiftUI

Alex Constancio
Published on MediumSeptember 23rd, 2024

In one of our recent products, we undertook an exciting challenge to redesign a map and search view. The design we implemented features a hybrid state, where a map sits behind a collapsible bottom sheet list view, very similar to popular applications like Apple Maps and Google Maps! This approach presented several challenges, especially since we needed to build this feature within a SwiftUI view while supporting iOS 15. One of the key difficulties was the lack of control that is now available in iOS 16+ with presentationDetents. I plan to cover presentationDetents more in depth in an upcoming article!

Example of the snap points and scroll behavior.

The Challenge

Our primary objective was to create a scrollable bottom sheet within SwiftUI to maintain consistency with other SwiftUI components. Initially, we attempted to handle all the logic within SwiftUI. However, we encountered significant obstacles, particularly around customizing gesture recognizers for a scrollable view.

This approach quickly became impractical due to the complexity and hackiness required to manage drag gestures and scrollable views effectively. You’d think Apple would have included something as basic as knowing when a drag gesture starts to change, especially in a framework that’s supposed to simplify UI development. But unfortunately, this isn’t directly provided in SwiftUI’s ScrollView and ScrollViewReader (as of iOS 15). So, we had to put our heads down and turn to classic UIKit to handle the drag gestures, ultimately leading to a more reusable and customized solution—though not without some challenges.

The Solution

What follows below is based on a simplified re-implementation in a sample project. The final implementation is a blend of UIKit and SwiftUI, resulting in a bottom sheet with multiple snap points that supports a scrollable list. The key component of this implementation is our custom DraggableScrollView view object, which wraps SwiftUI content in a UIScrollView and provides easy panning functionality through an offset parameter.

Here’s how the DraggableScrollView is used in the SwiftUI view struct:

DraggableScrollView(offset: offset) {
ForEach(0..<20) { item in
Text("List item \($0)")
}
}
.onDragChanged { value in
onDragChanged(by: value.translation.y)
}
.onDragEnded { value in
// Handle drag
}

This implementation offers the onDragChanged and onDragEnded view modifiers, which are essential for customizing behavior based on user interactions. These modifiers are key to controlling the list, giving us the vital information needed to make this work, which I'll discuss more in depth later on. This highlights one of the limitations of SwiftUI's ScrollView and ScrollViewReader in iOS 15 and earlier.

Implementation Details

Creating the DraggableScrollView

This view object integrates a SwiftUI content view with a UIScrollView, allowing for the required drag functionality.

UIViewRepresentable: The DraggableScrollView conforms to UIViewRepresentable, which is a protocol that lets us wrap UIKit view content in a SwiftUI View object.

struct DraggableScrollView<Content: View>: UIViewRepresentable {     
var offset: Binding<CGFloat>
var content: Content

func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
let hostingController = UIHostingController(rootView: content)
scrollView.addSubview(hostingController.view)

// Further setup...
return scrollView
}

func updateUIView(_ uiView: UIScrollView, context: Context) {
// Update the view...
}

func makeCoordinator() -> Coordinator {
Coordinator(self)
}
}

Coordinator: A coordinator class is created to manage the gesture recognizers and coordinate the scrolling.

class Coordinator: NSObject, UIGestureRecognizerDelegate {     
var parent: DraggableScrollView

init(_ parent: DraggableScrollView) {
self.parent = parent
}

@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
// Handle the pan gesture...
}
}

Gesture Recognizers: The UIScrollView is enhanced with a UIPanGestureRecognizer, whose delegate is the coordinator. This setup enables us to handle drag gestures and determine when to begin or end them.

func makeUIView(context: Context) -> UIScrollView {     
let scrollView = UIScrollView()
let hostingController = UIHostingController(rootView: content)
scrollView.addSubview(hostingController.view)
let panGesture = UIPanGestureRecognizer(
target: context.coordinator, action: #selector(context.coordinator.handlePan(_:))
)

panGesture.delegate = context.coordinator
scrollView.addGestureRecognizer(panGesture)
return scrollView
}

Custom Behavior with Gesture Recognizers

The handlePan method within the coordinator processes the pan gestures, updating the view state accordingly. By implementing gestureRecognizerShouldBegin, we can determine whether the gesture should proceed based on the current scroll position and the velocity of the pan gesture.

/// Determine whether to recognize if the gesture should drag the sheet, or to 
/// scroll the list.
func gestureRecognizerShouldBegin(
_ gestureRecognizer: UIGestureRecognizer
) -> Bool {
guard let panGesture = gestureRecognizer as? UIPanGestureRecognizer else {
return true
}
let velocity = panGesture.velocity(in: panGesture.view)
let isPanningDown = velocity.y > 0
let isScrolledToTop =
scrollView.collectionView.contentOffset.y <= 0

let isAtTop = (dragOffset == 0)
if !isScrolledToTop { return false }
if isPanningDown && isScrolledToTop { return true }
if !isPanningDown && !isAtTop { return true }

return false
}

Creating Snap Points

Moving over to our SwiftUI view struct. The onDragChangedand onDragEnded methods are particularly crucial for applying transformations to the bottom sheet based on the drag’s end state. Here are the transformations I came up with for achieving optimal snap points:

private func onDragChanged(
by translation: CGFloat
) {
let sheetPosition = translation + currentPosition
self.finishedTranslation = sheetPosition
}

private func onDragEnded(
screenHeight: Double,
translation: CGFloat,
velocity: CGFloat
) {
/// The current position of the bottom sheet
let sheetPosition = translation + currentPosition
/// 1/4 of the screens height
let quarterScreen = screenHeight / 4
/// 3/4 of the screens height
let threeFourthsScreen = quarterScreen * 3

/// Setting for the half screen position
let halfPosition = screenHeight / 2
/// Setting for the full screen position
let higherPosition: CGFloat = 0
/// Setting for the closed position
let closedPosition = screenHeight

withAnimation(.interactiveSpring()) {
// Adjust sheet position based on location sheet at end of gesture.
if sheetPosition < threeFourthsScreen {
// List is full screen
currentPosition = higherPosition
finishedTranslation = higherPosition
} else if sheetPosition > quarterScreen {
// List is half screen
currentPosition = halfPosition
finishedTranslation = halfPosition
} else {
// List is closed
currentPosition = closedPosition
finishedTranslation = closedPosition
}
}
}

Using this approach, we set different snap zones. This method leverages some key values, translation, and velocity, values not provided by default SwiftUI modifiers. This is important data needed to enable the bottom sheet to expand or collapse smoothly based on user interactions!

Conclusion

This hybrid approach, combining UIKit’s gesture handling with SwiftUI’s declarative UI framework, provided a great solution to our design challenge. The DraggableScrollView enables a smooth, interactive experience that behaves as expected within a bottom sheet!

Alex engineers with creativity at Livefront.

We talk the talk.
See how we walk the walk.

Our Work