I have created a Sentence creator
like in here(Pure SwiftUI). Button dragging and positioning is working fine. The problem is,
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
}
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)
}
}
}