Article
Creating a Dynamic Draggable ScrollView with UIKit for SwiftUI
September 23, 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!
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 onDragChanged
and 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!