0

I am trying to create a resizable split view in SwiftUI where a Map is on top and a List(which will contain locations later) is on the bottom, separated by a draggable handle. The user should be able to drag the handle to smoothly resize both the map and the list. The current implementation is functional but nowhere near usable because the dragging animation is very laggy and jittery. Also at times, Xcode continuously prints the following error to the console during the drag: Publishing changes from within view updates is not allowed, this will cause undefined behavior. I believe the issue is being caused by the continuous view rendering loop with the resizing of the map & list with the map probably trying to render the appropriate region to account for the change in size. Here's what I have so far:

import SwiftUI
import MapKit

struct DraggableSplitMapView: View {
    @State private var mapRegion = MKCoordinateRegion(
        center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
        span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
    )
    
    @State private var mapHeight: CGFloat = 300
    @GestureState private var dragOffset: CGSize = .zero

    var body: some View {
        NavigationView {
            GeometryReader { geometry in
                VStack(spacing: 0) {
                    
                    Map(coordinateRegion: $mapRegion)
                        .frame(height: calculateMapHeight(geometry: geometry))
                    
                    // Drag handle
                    ZStack {
                        Color.clear.frame(height: 12)
                        Capsule()
                            .fill(Color.secondary.opacity(0.5))
                            .frame(width: 40, height: 6)
                    }
                    .gesture(
                        DragGesture()
                            .updating($dragOffset) { value, state, _ in
                                state = value.translation
                            }
                            .onEnded { value in
                                let newHeight = self.mapHeight + value.translation.height
                                // Clamp the height from getting too big or small.
                                self.mapHeight = max(100, min(geometry.size.height - 150, newHeight))
                            }
                    )
                    
                    // Dummy list
                    List(0..<50) { i in
                        Text("List Item \(i)")
                    }
                    .listStyle(.plain)
                }
            }
            .navigationTitle("Draggable Map")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
    
    private func calculateMapHeight(geometry: GeometryProxy) -> CGFloat {
        let proposedHeight = self.mapHeight + self.dragOffset.height
        // Ensure the height stays within reasonable bounds during the drag.
        return max(100, min(geometry.size.height - 150, proposedHeight))
    }
}

struct DraggableSplitMapView_Previews: PreviewProvider {
    static var previews: some View {
        DraggableSplitMapView()
    }
}

The map will contain several hundred annotations displayed later so I think the issue will only get even worse with data bound to the map. How can I fix this issue and achieve smooth dragging while still using the native SwiftUI Map view? Is there a way to prevent the Map from writing back to its region binding during a drag gesture, or a better way to structure this view to avoid the conflict? Any help is appreciated.

0

2 Answers 2

0

I'll try to answer your question with another question.
If you drag the Capsule up 10 float, and then the View scales accordingly, and then the capsule lands underneath of your new finger position... Without ending the gesture, what is the current Value.translation.height?

0?
10?

Technically, the capsule is right where it began, underneath of your finger, so 0. But it took a trip to get there, so 10?
I think this computing confusion is the source of the jitters.

If this were true, then adding each cycle of the value.translation.height to the viewSize (instead of factoring in its current value) would solve the issue. And it does.

struct CustomSplitView: View {
  
  @State private var height: CGFloat = 400
    
  var body: some View {
    
    VStack ( spacing: 0 ) {
      
      Color.blue
        .frame ( height: height )
      
      Capsule()
        .fill ( Color.secondary.opacity ( 0.5 ) )
        .frame ( width: 40, height: 6 )
        .frame ( height: 12 )
        .gesture ( DragGesture()
          .onChanged ( self.dragChanged )
        )
      
      Color.green
      
    }
  }
  private func dragChanged ( value: DragGesture.Value ) {
    self.height += value.translation.height <-- here _-_-_-_-_-_-_-_-_-_-_-_-_-_-_-
    self.height = self.height .clamp ( lower: 100 , upper: 600 )
  }
}

public extension Comparable {
  func clamp ( lower: Self , upper: Self ) -> Self {
    min ( max ( self , lower ) , upper )
  }
}
Sign up to request clarification or add additional context in comments.

Comments

-1

This issue is similar to the one in Resizing frame with DragGesture causes jittering. The position of the drag handle is being determined by the height of the map, but the height of the map is being determined by the drag gesture on the handle. This inter-dependency causes the jittery behavior.

To fix this particular case, you could try reserving space for the map by adding top-padding to the drag handle, then show the map as an overlay in this space. This way, the position of the drag handle no longer depends on the map. Instead, the size of the map depends on the size of the padding above the drag handle.

It is important that the drag gesture is applied after the top padding, so that in fact the gesture applies to the full area of handle + padding. However, if a drag is started in the padding/overlay region then it is intercepted by the Map and causes the map position to be dragged (as you would want it to), it does not cause the map area to be re-sized. This happens automatically and does not require any extra code.

You could also consider re-using the function calculateMapHeight for setting the map height at end-of-drag, instead of repeating the logic in .onEnded. This requires passing the drag height as a parameter to the function. While we're at it, it might be simpler to pass the height of the container, instead of the GeometryProxy:

private func calculateMapHeight(containerHeight: CGFloat, dragHeight: CGFloat) -> CGFloat {
    let proposedHeight = mapHeight + dragHeight
    // Ensure the height stays within reasonable bounds during the drag.
    return max(100, min(containerHeight - 150, proposedHeight))
}

The body function can then be updated as follows:

var body: some View {
    NavigationStack { // NavigationView is deprecated
        GeometryReader { geometry in
            let containerHeight = geometry.size.height
            VStack(spacing: 0) {

                // Drag handle
                Capsule()
                    .fill(Color.secondary.opacity(0.5))
                    .frame(width: 40, height: 6)
                    .frame(maxWidth: .infinity)
                    .padding(.vertical, 7)
                    .padding(.top, calculateMapHeight(
                        containerHeight: containerHeight,
                        dragHeight: dragOffset.height
                    ))
                    .contentShape(.rect)
                    .gesture(
                        DragGesture()
                            .updating($dragOffset) { value, state, _ in
                                state = value.translation
                            }
                            .onEnded { value in
                                mapHeight = calculateMapHeight(
                                    containerHeight: containerHeight,
                                    dragHeight: value.translation.height
                                )
                            }
                    )
                    .overlay(alignment: .top) {
                        Map(coordinateRegion: $mapRegion) // ⚠️ deprecated
                            .padding(.bottom, 20) // handle height
                    }

                // Dummy list
                List(0..<50) { i in
                    Text("List Item \(i)")
                }
                .listStyle(.plain)
            }
        }
        .navigationTitle("Draggable Map")
        .navigationBarTitleDisplayMode(.inline)
    }
}

Animation


You were also asking:

Is there a way to prevent the Map from writing back to its region binding during a drag gesture, or a better way to structure this view to avoid the conflict?

One option might be to show the map at maximum size, then clip it to the available space.

EDIT To try it this way, change the overlay to the following:

.overlay(alignment: .top) {
    Map(coordinateRegion: $mapRegion)
        .frame(height: calculateMapHeight(
            containerHeight: containerHeight,
            dragHeight: 9999 // Any large value
        ))
        .frame(height: calculateMapHeight(
            containerHeight: containerHeight,
            dragHeight: dragOffset.height
        ))
        .clipped()
        .contentShape(.rect)
}

Doing it this way will probably mean that the binding does not get updated at all, so I guess this means, you won't know which part of the map is actually visible. Initializing a Map in this way is actually deprecated, so it would probably be better to be tracking the area a different way anyway, but I'm afraid I'm not able to suggest exactly how. You might like to post a new question that is dedicated to this issue if you are not able to find a better way to do it.

4 Comments

I disagree, I think the “reserved space” feels out-of-the-way. And I think the jitters are due to a misunderstanding of how the DragGesture reacts to a moving handle. Unless you see something I don’t? See my answer below please.
There are often different ways to solve a problem and I for one find it interesting to see the different approaches that developers come up with. We all have our own preferences about what represents a robust solution, just as we apparently have our own preferences when it comes to code formatting style.
Thanks @Benzy Neez. This mostly works but I noticed a couple of small things. Firstly, the map zooms out with repeated dragging down and zooms in when dragging up. Secondly, if I have annotations on the map, they seem to disappear during the drag and reappear when the drag ends. I assume both of these are due to the re-rendering of the map with the drag gesture. Any way to address these? I believe the full size map with clipping may work to resolve these
Answer updated to show how to use the clipping technique - please see the end part after the divider.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.