iosswiftuihstackdraggesture

SwiftUI keep background stationary as view is dragged


I have implemented a sample video editing timeline using SwiftUI and am facing issues. So I am breaking up the problem in chunks and posting issue each as a separate question. In the code below, I have a simple timeline using an HStack comprising of a left spacer, right spacer(represented as simple black color) and a trimmer UI in the middle. The trimmer resizes as the left and right handles are dragged. The left and right spacers also adjust in width as the trimmer handles are dragged.

Problem: I want to keep the background thumbnails (implemented currently as simple Rectangles filled in different colors) in the trimmer stationary as the trimmer resizes. Currently they move along as the trimmer resizes as seen in the gif below. How do I fix it?

enter image description here

import SwiftUI

struct SampleTimeline: View {
    
    let viewWidth:CGFloat = 340 //Width of HStack container for Timeline
   
    @State var frameWidth:CGFloat = 280 //Width of trimmer
    
    var minWidth: CGFloat {
        2*chevronWidth + 10
    } //min Width of trimmer
    
    @State private var leftViewWidth:CGFloat = 20
    @State private var rightViewWidth:CGFloat = 20
    
    var chevronWidth:CGFloat {
        return 24
    }
    
    var body: some View {
        
        HStack(spacing:0) {
            Color.black
                .frame(width: leftViewWidth)
                .frame(height: 70)
            
            HStack(spacing: 0) {
            
                Image(systemName: "chevron.compact.left")
                    .frame(width: chevronWidth, height: 70)
                    .background(Color.blue)
                    .gesture(
                        DragGesture(minimumDistance: 0)
                            .onChanged({ value in
                                leftViewWidth = max(leftViewWidth + value.translation.width, 0)
                                
                                if leftViewWidth > viewWidth - minWidth - rightViewWidth {
                                    leftViewWidth = viewWidth - minWidth - rightViewWidth
                                }
                                   
                                frameWidth = max(viewWidth - leftViewWidth - rightViewWidth, minWidth)
                                
                            })
                            .onEnded { value in
                               
                            }
                    )
        
                Spacer()
                
                Image(systemName: "chevron.compact.right")
                    .frame(width: chevronWidth, height: 70)
                    .background(Color.blue)
                    .gesture(
                        DragGesture(minimumDistance: 0)
                            .onChanged({ value in
                                rightViewWidth = max(rightViewWidth - value.translation.width, 0)
                                
                                if rightViewWidth > viewWidth - minWidth - leftViewWidth {
                                    rightViewWidth = viewWidth - minWidth - leftViewWidth
                                }
                                
                                frameWidth = max(viewWidth - leftViewWidth - rightViewWidth, minWidth)
                            })
                            .onEnded { value in
                              
                            }
                    )
                 
            }
            .foregroundColor(.black)
            .font(.title3.weight(.semibold))
          
            .background {
                
                HStack(spacing:0) {
                    Rectangle().fill(Color.red)
                        .frame(width: 70, height: 60)
                    Rectangle().fill(Color.cyan)
                        .frame(width: 70, height: 60)
                    Rectangle().fill(Color.orange)
                        .frame(width: 70, height: 60)
                    Rectangle().fill(Color.brown)
                        .frame(width: 70, height: 60)
                    Rectangle().fill(Color.purple)
                        .frame(width: 70, height: 60)
                }
                
            }
            .frame(width: frameWidth)
            .clipped()
            
            Color.black
                .frame(width: rightViewWidth)
                .frame(height: 70)
        }
        .frame(width: viewWidth, alignment: .leading)
    }
}

#Preview {
    SampleTimeline()
}

Update: I have managed to solve problem as follows, but I still feel it is kinda workaround(as I have set an offset to the thumbnails view). Please post a better and accurate solution if you think there is any (such as by using masking that also reduces the frame width of the trimmer at the same time).


import SwiftUI

struct SampleTimeline: View {
    
    let viewWidth:CGFloat = 340 //Width of HStack container for Timeline
   
    @State var frameWidth:CGFloat = 280 //Width of trimmer
    
    var minWidth: CGFloat {
        2*chevronWidth + 10
    } //min Width of trimmer
    
    @State private var leftViewWidth:CGFloat = 20
    @State private var rightViewWidth:CGFloat = 20
    @GestureState private var leftEndPanned = false
    @GestureState private var rightEndPanned = false
    
    var chevronWidth:CGFloat {
        return 24
    }
    
    var body: some View {
        
        HStack(spacing:0) {
            Color.clear
                .frame(width: leftViewWidth)
                .frame(height: 70)
            
            HStack(spacing: 0) {
            
                Image(systemName: "chevron.compact.left")
                    .frame(width: chevronWidth, height: 70)
                    .background(Color.blue)
                    .gesture(
                        DragGesture(minimumDistance: 0)
                            .updating($leftEndPanned, body: { _, state, _ in
                                state = true
                            })
                            .onChanged({ value in
                                leftViewWidth = max(leftViewWidth + value.translation.width, 0)
                                
                                if leftViewWidth > viewWidth - minWidth - rightViewWidth {
                                    leftViewWidth = viewWidth - minWidth - rightViewWidth
                                }
                                   
                                frameWidth = max(viewWidth - leftViewWidth - rightViewWidth, minWidth)
                                
                            })
                            .onEnded { value in
                               
                            }
                    )
        
                Spacer()
                
                Image(systemName: "chevron.compact.right")
                    .frame(width: chevronWidth, height: 70)
                    .background(Color.blue)
                    .gesture(
                        DragGesture(minimumDistance: 0)
                            .updating($rightEndPanned, body: { _, state, _ in
                                state = true
                            })
                            .onChanged({ value in
                                rightViewWidth = max(rightViewWidth - value.translation.width, 0)
                                
                                if rightViewWidth > viewWidth - minWidth - leftViewWidth {
                                    rightViewWidth = viewWidth - minWidth - leftViewWidth
                                }
                                
                                frameWidth = max(viewWidth - leftViewWidth - rightViewWidth, minWidth)
                            })
                            .onEnded { value in
                              
                            }
                    )
                 
            }
            .foregroundColor(.black)
            .font(.title3.weight(.semibold))
            .background {
                
                HStack(spacing:0) {
                    Rectangle().fill(Color.red)
                        .frame(width: 70, height: 60)
                    Rectangle().fill(Color.cyan)
                        .frame(width: 70, height: 60)
                    Rectangle().fill(Color.orange)
                        .frame(width: 70, height: 60)
                    Rectangle().fill(Color.brown)
                        .frame(width: 70, height: 60)
                    Rectangle().fill(Color.purple)
                        .frame(width: 70, height: 60)
                }
                .frame(width: viewWidth - leftViewWidth - rightViewWidth, alignment: .leading)
                .offset(x: -leftViewWidth)
                .background(Color.yellow)
                .clipped()
                
            }
            
            
            Color.clear
                .frame(width: rightViewWidth)
                .frame(height: 70)
        }
        .frame(width: viewWidth, alignment: .leading)
    }
}

#Preview {
    SampleTimeline()
}


Solution

  • Currently, the colors are shown in the background of the nested HStack. However, this HStack is moving, depending on the size of leftViewWidth. So although you are setting a smaller frame size on the background, you are not compensating for the stack's position.

    I would suggest using a different approach. Instead of trying to clip the background, just leave it unchanged and apply a .mask to control which part of it should be visible.

    You previously accepted another answer for dragging the trimmers which used GestureState variables to track the drag position (it was my answer). The example in the question is doing it a different way. The version below is based on the same solution as I provided before. I would suggest, this is a simpler way to do it.

    struct SampleTimeline: View {
        let viewWidth: CGFloat = 340 //Width of HStack container for Timeline
        let chevronWidth: CGFloat = 24
        let minWidth: CGFloat = 10
    
        @State private var leftOffset: CGFloat = 0
        @State private var rightOffset: CGFloat = 0
        @GestureState private var leftDragOffset: CGFloat = 0
        @GestureState private var rightDragOffset: CGFloat = 0
    
        private func leftAdjustment(dragOffset: CGFloat) -> CGFloat {
            let maxAdjustment = viewWidth - rightOffset - (2 * chevronWidth) - minWidth
            return max(0, min(leftOffset + dragOffset, maxAdjustment))
        }
    
        private func rightAdjustment(dragOffset: CGFloat) -> CGFloat {
            let maxAdjustment = viewWidth - leftOffset - (2 * chevronWidth) - minWidth
            return max(0, min(rightOffset - dragOffset, maxAdjustment))
        }
    
    //    private var frameWidth: CGFloat {
    //        viewWidth
    //        - (2 * chevronWidth)
    //        - leftAdjustment(dragOffset: leftDragOffset)
    //        - rightAdjustment(dragOffset: rightDragOffset)
    //    }
    
        var body: some View {
            HStack(spacing: 0) {
    
                Image(systemName: "chevron.compact.left")
                    .frame(width: chevronWidth, height: 70)
                    .background(Color.blue)
                    .offset(x: leftAdjustment(dragOffset: leftDragOffset))
                    .gesture(
                        DragGesture(minimumDistance: 0)
                            .updating($leftDragOffset) { value, state, trans in
                                state = value.translation.width
                            }
                            .onEnded { value in
                                leftOffset = leftAdjustment(dragOffset: value.translation.width)
                            }
                    )
    
                Spacer()
    
                Image(systemName: "chevron.compact.right")
                    .frame(width: chevronWidth, height: 70)
                    .background(Color.blue)
                    .offset(x: -rightAdjustment(dragOffset: rightDragOffset))
                    .gesture(
                        DragGesture(minimumDistance: 0)
                            .updating($rightDragOffset) { value, state, trans in
                                state = value.translation.width
                            }
                            .onEnded { value in
                                rightOffset = rightAdjustment(dragOffset: value.translation.width)
                            }
                    )
    
            }
            .foregroundColor(.black)
            .font(.title3.weight(.semibold))
            .background {
                HStack(spacing: 0) {
                    Color.red
                    Color.cyan
                    Color.orange
                    Color.brown
                    Color.purple
                }
                .padding(.vertical, 5)
                .padding(.horizontal, chevronWidth)
                .background(.background)
                .mask {
                    Rectangle()
                        .padding(.leading, leftAdjustment(dragOffset: leftDragOffset))
                        .padding(.trailing, rightAdjustment(dragOffset: rightDragOffset))
                }
            }
            .frame(width: viewWidth)
            .background(.black)
        }
    }
    

    Animation