Search code examples
iosswiftswiftui

Button dragging visible issue in swiftUI


I have created a Sentence creator like in here(Pure SwiftUI). Button dragging and positioning is working fine. The problem is,

  • While dragging the button, it will not displaying on Sentence area & wise versa. (There are 2 areas, Sentence & Button area)
  • After dropping the button it's positioned without any issue.
  • Other behaviors are working as expected

Can anyone help me how to fix this issue?

struct SwiftUIDraggableButtonView: View {
    @State private var buttons: [DraggableButtonModel] = [
        DraggableButtonModel(id: 1, text: "me"),
        DraggableButtonModel(id: 2, text: "foreign friends."),
        DraggableButtonModel(id: 3, text: "to me,"),
        DraggableButtonModel(id: 4, text: "to make"),
        DraggableButtonModel(id: 5, text: "means")
    ]
    @State private var sentenceButtons: [DraggableButtonModel] = []

    var body: some View {
        VStack(spacing: 20) {
            // Unified FlowLayout for sentence and default areas
            FlowLayout(spacing: 5, items: sentenceButtons) { button in
                DraggableButtonNew(
                    model: button,
                    isInSentence: true
                ) { action in
                    handleButtonAction(action, button: button)
                }
                .transition(.move(edge: .bottom))
            }
            .padding()
            .frame(height: 200)  // Set height for sentence area
            .background(Color.gray)
            .zIndex(0)

            // Default button area
            FlowLayout(spacing: 10, items: buttons) { button in
                DraggableButtonNew(
                    model: button,
                    isInSentence: false
                ) { action in
                    handleButtonAction(action, button: button)
                }
                .allowsHitTesting(!button.isDisabled)  // Disable interaction for disabled buttons
                .transition(.move(edge: .top))
            }
            .frame(height: 200)  // Set height for default button area
            .zIndex(1)
            Spacer()
        }
        .padding()
    }

    private func handleButtonAction(_ action: DraggableButtonAction, button: DraggableButtonModel) {
        withAnimation {
            switch action {
            case .tap, .drag:
                // Handle button tap: move button between sentence and default areas
                if let index = sentenceButtons.firstIndex(where: { $0.id == button.id }) {
                    // Button is in the sentence area, move it back to default
                    sentenceButtons.remove(at: index)
                    if let defaultIndex = buttons.firstIndex(where: { $0.id == button.id }) {
                        buttons[defaultIndex].isDisabled = false // Re-enable in default area
                    }
                } else if let defaultIndex = buttons.firstIndex(where: { $0.id == button.id }),
                          !buttons[defaultIndex].isDisabled {
                    // Button is in default area, move it to sentence
                    buttons[defaultIndex].isDisabled = true
                    sentenceButtons.append(button)
                }
            }
        }
    }
}

// FlowLayout for wrapping buttons in multiple lines
struct FlowLayout<Data: RandomAccessCollection, Content: View>: View
where Data.Element: Identifiable {
    let spacing: CGFloat
    let items: Data
    let content: (Data.Element) -> Content

    var body: some View {
        var width: CGFloat = 0
        var height: CGFloat = 0

        return GeometryReader { geometry in
            ZStack(alignment: .topLeading) {
                ForEach(items) { item in
                    content(item)
                        .alignmentGuide(.leading) { d in
                            if abs(width - d.width) > geometry.size.width {
                                width = 0
                                height -= d.height + spacing
                            }
                            let result = width
                            if item.id == items.last?.id {
                                width = 0
                            } else {
                                width -= d.width + spacing
                            }
                            return result
                        }
                        .alignmentGuide(.top) { _ in
                            let result = height
                            if item.id == items.last?.id {
                                height = 0
                            }
                            return result
                        }
                }
            }
        }
        .frame(maxHeight: .infinity, alignment: .topLeading)
    }
}
// Draggable Button
struct DraggableButtonNew: View {
    let model: DraggableButtonModel
    let isInSentence: Bool
    let actionHandler: (DraggableButtonAction) -> Void
    @State private var offset: CGSize = .zero

    var body: some View {
        Text(model.text)
            .padding(8)
            .background(isInSentence ? Color.orange : model.isDisabled ? Color.gray : Color.orange)
            .foregroundColor(Color.white)
            .offset(offset)
            .zIndex(offset == .zero ? 0 : 1)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        offset = value.translation
                    }
                    .onEnded { _ in
                        actionHandler(.drag)
                        offset = .zero
                    }
            )
            .onTapGesture {
                actionHandler(.tap)
            }
    }
}

// Button Model
struct DraggableButtonModel: Identifiable, Equatable {
    let id: Int
    let text: String
    var isDisabled: Bool = false
}
enum DraggableButtonAction {
    case tap
    case drag
}

Solution

  • The changes for a drag gesture looks a bit more fluid if you use .matchedGeometryEffect to match up the positions of the words in the sentence and the buttons. The issue of overlap can also be solved this way.

    One way to get it working is as follows:

    • The words inside the sentence area and the buttons in the button area are shown as simple hidden placeholders.

    • The ids of the placeholders are based on the ids of the buttons (words), but a constant is added to the ids of the placeholders in the sentence area. This is to make the ids of all placeholders unique.

    • The draggable words are shown in an overlay over the parent VStack. These words are always shown as enabled (orange), but their positions are matched to the placeholders using .matchedGeometryEffect. In other words, these words have a floating position.

    • The word being dragged has a higher zIndex (as before). This ensures that it is shown over the other words if dragged over them. This resolves the original issue in the post.

    • When a word is added to the sentence, the disabled form is shown in the background of the button position.

    • When dragging a word, I was seeing errors in the console about Invalid sample AnimatablePair. These can be prevented by updating the offset using withAnimation and a duration of 0, see this post for more details.

    • In order that the animation works smoothly after a drag is released, it is important that the end-of-drag actions are also performed withAnimation.

    Other suggestions:

    • The parameter isInSentence in DraggableButtonNew is redundant and can be dropped. It might actually make more sense for the model parameter isDisabled to be renamed to isInSentence and then used in reverse: false becomes true.

    • The modifier .foregroundColor is deprecated, use .foregroundStyle instead.

    // SwiftUIDraggableButtonView
    
    let sentenceWord = 1_000_000
    @Namespace private var namespace
    
    var body: some View {
        VStack(spacing: 20) {
            // Unified FlowLayout for sentence and default areas
            FlowLayout(spacing: 5, items: sentenceButtons) { button in
    
                // Placeholder for word in sentence
                PlainWord(text: button.text)
                    .hidden()
                    .matchedGeometryEffect(id: sentenceWord + button.id, in: namespace, isSource: true)
            }
            .padding()
            .frame(height: 200)  // Set height for sentence area
            .background(.gray)
    
            // Default button area
            FlowLayout(spacing: 10, items: buttons) { button in
    
                // Placeholder for a button
                PlainWord(text: button.text)
                    .hidden()
                    .matchedGeometryEffect(id: button.id, in: namespace, isSource: true)
                    .background {
                        if button.isDisabled {
                            PlainWord(text: button.text, isDisabled: true)
                        }
                    }
            }
            .frame(height: 200)  // Set height for default button area
            Spacer()
        }
        .overlay {
            ForEach(buttons) { button in
                DraggableButtonNew(model: button) { action in
                    handleButtonAction(action, button: button)
                }
                .matchedGeometryEffect(
                    id: button.isDisabled ? sentenceWord + button.id : button.id,
                    in: namespace,
                    isSource: false
                )
            }
        }
        .padding()
    }
    
    struct PlainWord: View {
        let text: String
        var isDisabled = false
    
        var body: some View {
            Text(text)
                .padding(8)
                .background(isDisabled ? .gray : .orange)
                .foregroundStyle(.white)
        }
    }
    
    // Draggable Button
    struct DraggableButtonNew: View {
        let model: DraggableButtonModel
        let actionHandler: (DraggableButtonAction) -> Void
        @State private var offset: CGSize = .zero
    
        var body: some View {
            PlainWord(text: model.text, isDisabled: false)
                .offset(offset)
                .zIndex(offset == .zero ? 0 : 1)
                .gesture(
                    DragGesture()
                        .onChanged { value in
                            withAnimation(.easeInOut(duration: 0)) {
                                offset = value.translation
                            }
                        }
                        .onEnded { _ in
                            withAnimation {
                                actionHandler(.drag)
                                offset = .zero
                            }
                        }
                )
                .onTapGesture {
                    actionHandler(.tap)
                }
        }
    }
    

    Animation