Drag & Drop

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
local GuiService = game:GetService("GuiService")
local HttpService = game:GetService("HttpService")
local UserInputService = game:GetService("UserInputService")
-- [Fusion imports omitted for clarity]

-- This example shows a full drag-and-drop implementation for mouse input only.
-- Extending this system to generically work with other input types, such as
-- touch gestures or gamepads, is left as an exercise to the reader. However, it
-- should robustly support dragging many types of UI around flexibly.

-- To ensure best accessibility, any interactions you implement shouldn't force
-- the player to hold the mouse button down. Either allow drag-and-drop using
-- single inputs, or provide a non-dragging alternative; this will ensure that
-- players with reduced motor ability aren't locked out of UI functions.

-- We're going to need to account for the UI inset sometimes. We cache it here.
local TOP_LEFT_INSET = GuiService:GetGuiInset()

-- To reflect the current position of the cursor on-screen, we'll use a state
-- object that's updated using UserInputService.
local mousePos = Value(UserInputService:GetMouseLocation() - TOP_LEFT_INSET)
local mousePosConn = UserInputService.InputChanged:Connect(function(inputObject)
    if inputObject.UserInputType == Enum.UserInputType.MouseMovement then
        mousePos:set(Vector2.new(inputObject.Position.X, inputObject.Position.Y))
    end
end)

-- We need to keep drag of which item is currently being dragged. Only one item
-- can be dragged at a time. This type stores all the information needed:
export type CurrentlyDragging = {
    -- Each draggable item will have a unique ID; the ID stored here represents
    -- which item is being dragged right now. We'll use strings for this, but
    -- you could use numbers if that's more convenient for you.
    id: string,
    -- When a drag is started, we store the mouse's offset relative to the item
    -- being dragged. When the mouse moves, we can then apply the same offset to
    -- make it look like the item is 'pinned' to the cursor.
    offset: Vector2
}
-- This state object stores the above during a drag, or `nil` when not dragging.
local currentlyDragging = Value(nil :: CurrentlyDragging?)

-- Now we need a component to encapsulate all of our dragging behaviour, such
-- as moving our UI between different parents, placing it at the mouse cursor,
-- managing sizing, and so on.

export type DraggableProps = {
    -- This should uniquely identify the draggable item apart from all other
    -- draggable items. This is constant and so shouldn't be a state object.
    ID: string,
    -- It doesn't make sense for a draggable item to have a constant parent. You
    -- wouldn't be able to drop it anywhere else, so we enforce that Parent is a
    -- state object for our own convenience.
    Parent: StateObject<Instance?>,
    -- When an item is being dragged, it needs to appear above all other UI. We
    -- will create an overlay frame that fills the screen to achieve this.
    OverlayFrame: Instance,
    -- To start a drag, we'll need to know where the top-left corner of the item
    -- is, so we can calculate `currentlyDragging.offset`. We'll allow the
    -- calling code to pass through a Value object to [Out "AbsolutePosition"].
    OutAbsolutePosition: Value<Vector2?>?,

    Name: CanBeState<string>?,
    LayoutOrder: CanBeState<number>?,
    Position: CanBeState<UDim2>?,
    AnchorPoint: CanBeState<Vector2>?,
    Size: CanBeState<UDim2>?,
    AutomaticSize: CanBeState<Enum.AutomaticSize>?,
    ZIndex: CanBeState<number>?,
    [Children]: Child
}

local function Draggable(props: DraggableProps): Child
    -- If we need something to be cleaned up when our item is destroyed, we can
    -- add it to this array. It'll be passed to `[Cleanup]` later.
    local cleanupTasks = {}

    -- This acts like `currentlyDragging`, but filters out anything without a
    -- matching ID, so it'll only exist when this specific item is dragged.
    local thisDragging = Computed(function()
        local dragInfo = currentlyDragging:get()
        return if dragInfo ~= nil and dragInfo.id == props.ID then dragInfo else nil
    end)

    -- Our item controls its own parent - one of the few times you'll see this
    -- done in Fusion. This means we don't have to destroy and re-build the item
    -- when it moves to a new location.
    local itemParent = Computed(function()
        return if thisDragging:get() ~= nil then props.OverlayFrame else props.Parent:get()
    end, Fusion.doNothing)

    -- If we move a scaled UI into the `overlayBox`, by default it will stretch
    -- to the screen size. Ideally we want it to preserve its current size while
    -- it's being dragged, so we need to track the parent's size and calculate
    -- the item size ourselves.

    -- To start with, we'll store the parent's absolute size. This takes a bit
    -- of legwork to get right, and we need to remember the UI might not have a
    -- parent which we can measure the size of - we'll represent that as `nil`.
    -- Feel free to extract this into a separate function if you want to.
    local parentSize = Value(nil)
    do
        -- We'll call this whenever the parent's AbsoluteSize changes, or when
        -- the parent changes (because different parents might have different
        -- absolute sizes, if any)
        local function recalculateParentSize()
            -- We're not in a Computed, so we want to pass `false` to `:get()`
            -- to avoid adding dependencies.
            local parent = props.Parent:get(false)
            local parentHasSize = parent ~= nil and parent:IsA("GuiObject")
            parentSize:set(if parentHasSize then parent.AbsoluteSize else nil)
        end

        -- We don't just need to connect to the AbsoluteSize changed event of
        -- the parent we have *right now*! If the parent changes, we need to
        -- disconnect the old event and re-connect on the new parent, which we
        -- do here.
        local parentSizeConn = nil
        local function rebindToParentSize()
            if parentSizeConn ~= nil then
                parentSizeConn:Disconnect()
                parentSizeConn = nil
            end
            local parent = props.Parent:get(false)
            local parentHasSize = parent ~= nil and parent:IsA("GuiObject")
            if parentHasSize then
                parentSizeConn = parent:GetPropertyChangedSignal("AbsoluteSize"):Connect(recalculateParentSize)
            end
            recalculateParentSize()
        end
        rebindToParentSize()
        local disconnect = Observer(props.Parent):onChange(rebindToParentSize)

        -- When the item gets destroyed, we need to disconnect that observer and
        -- our AbsoluteSize change event (if any is active right now)
        table.insert(cleanupTasks, function()
            disconnect()
            if parentSizeConn ~= nil then
                parentSizeConn:Disconnect()
                parentSizeConn = nil
            end
        end)
    end

    -- Now that we have a reliable parent size, we can calculate the item's size
    -- without worrying about all of those event connections.
    if props.Size == nil then
        props.Size = Value(UDim2.fromOffset(0, 0))
    elseif typeof(props.Size) == "UDim2" then
        props.Size = Value(props.Size)
    end
    local itemSize = Computed(function()
        local udim2 = props.Size:get()
        local scaleSize = parentSize:get() or Vector2.zero -- might be nil!
        return UDim2.fromOffset(
            udim2.X.Scale * scaleSize.X + udim2.X.Offset,
            udim2.Y.Scale * scaleSize.Y + udim2.Y.Offset
        )
    end)

    -- Similarly, we'll need to override the item's position while it's being
    -- dragged. Happily, this is simpler to do :)
    if props.Position == nil then
        props.Position = Value(UDim2.fromOffset(0, 0))
    elseif typeof(props.Position) == "UDim2" then
        props.Position = Value(props.Position)
    end
    local itemPosition = Computed(function()
        local dragInfo = thisDragging:get()
        if dragInfo == nil then
            return props.Position:get()
        else
            -- `dragInfo.offset` stores the distance from the top-left corner
            -- of the item to the mouse position. Subtracting the offset from
            -- the mouse position therefore gives us the item's position.
            local position = mousePos:get() - dragInfo.offset
            return UDim2.fromOffset(position.X, position.Y)
        end
    end)

    return New "Frame" {
        Name = props.Name or "Draggable",
        LayoutOrder = props.LayoutOrder,
        AnchorPoint = props.AnchorPoint,
        AutomaticSize = props.AutomaticSize,
        ZIndex = props.ZIndex,

        Parent = itemParent,
        Position = itemPosition,
        Size = itemSize,

        BackgroundTransparency = 1,

        [Out "AbsolutePosition"] = props.OutAbsolutePosition,

        [Children] = props[Children]
    }
end

-- The hard part is over! Now we just need to create some draggable items and
-- start/stop drags in response to mouse events. We'll use a very basic example.

-- Let's make some to-do items. They'll show up in two lists - one for
-- incomplete tasks, and another for complete tasks. You'll be able to drag
-- items between the lists to mark them as complete. The lists will be sorted
-- alphabetically so we don't have to deal with calculating where the items
-- should be placed when they're dropped.

export type TodoItem = {
    id: string,
    text: string,
    completed: Value<boolean>
}
local todoItems: Value<TodoItem> = {
    {
        -- You can use HttpService to easily generate unique IDs statelessly.
        id = HttpService:GenerateGUID(),
        text = "Wake up today",
        completed = Value(true)
    },
    {
        id = HttpService:GenerateGUID(),
        text = "Read the Fusion docs",
        completed = Value(true)
    },
    {
        id = HttpService:GenerateGUID(),
        text = "Take over the universe",
        completed = Value(false)
    }
}
local function getTodoItemForID(id: string): TodoItem?
    for _, item in todoItems do
        if item.id == id then
            return item
        end
    end
    return nil
end

-- These represent the individual draggable to-do item entries in the lists.
-- This is where we'll use our `Draggable` component!
export type TodoEntryProps = {
    Item: TodoItem,
    Parent: StateObject<Instance?>,
    OverlayFrame: Instance,
}
local function TodoEntry(props: TodoEntryProps): Child
    local absolutePosition = Value(nil)

    -- Using our item's ID, we can figure out if we're being dragged to apply
    -- some styling for dragged items only!
    local isDragging = Computed(function()
        local dragInfo = currentlyDragging:get()
        return dragInfo ~= nil and dragInfo.id == props.Item.id
    end)

    return Draggable {
        ID = props.Item.id,
        Parent = props.Parent,
        OverlayFrame = props.OverlayFrame,
        OutAbsolutePosition = absolutePosition,

        Name = props.Item.text,
        Size = UDim2.new(1, 0, 0, 50),

        [Children] = New "TextButton" {
            Name = "TodoEntry",

            Size = UDim2.fromScale(1, 1),
            BackgroundColor3 = Computed(function()
                if isDragging:get() then
                    return Color3.new(1, 1, 1)
                elseif props.Item.completed:get() then
                    return Color3.new(0, 1, 0)
                else
                    return Color3.new(1, 0, 0)
                end
            end),
            Text = props.Item.text,
            TextSize = 28,

            -- This is where we'll detect mouse down. When the mouse is pressed
            -- over this item, we should pick it up.
            [OnEvent "MouseButton1Down"] = function()
                -- only start a drag if we're not already dragging
                if currentlyDragging:get(false) == nil then
                    local itemPos = absolutePosition:get(false) or Vector2.zero
                    local offset = mousePos:get(false) - itemPos
                    currentlyDragging:set({
                        id = props.Item.id,
                        offset = offset
                    })
                end
            end

            -- We're not going to detect mouse up here, because in some rare
            -- cases the event could be dropped due to lag between the item's
            -- position and the cursor position. We'll deal with this at a
            -- global level instead.
        }
    }
end

-- Now we should construct our two task lists for housing our to-do entries.
-- Notice that they don't manage the entries themselves! The entries don't
-- belong to these lists after all, so that'd be nonsense :)

-- When we release our mouse, we need to know where to drop any dragged item we
-- have. This will tell us if we're hovering over either list.
local dropAction = Value(nil)

local incompleteList = New "ScrollingFrame" {
    Name = "IncompleteTasks",
    Position = UDim2.fromScale(0.1, 0.1),
    Size = UDim2.fromScale(0.35, 0.9),

    BackgroundTransparency = 0.75,
    BackgroundColor3 = Color3.new(1, 0, 0),

    [OnEvent "MouseEnter"] = function()
        dropAction:set("incomplete")
    end,

    [OnEvent "MouseLeave"] = function()
        if dropAction:get(false) == "incomplete" then
            dropAction:set(nil) -- only clear this if it's not overwritten yet
        end
    end,

    [Children] = {
        New "UIListLayout" {
            SortOrder = "Name",
            Padding = UDim.new(0, 5)
        }
    }
}

local completedList = New "ScrollingFrame" {
    Name = "CompletedTasks",
    Position = UDim2.fromScale(0.55, 0.1),
    Size = UDim2.fromScale(0.35, 0.9),

    BackgroundTransparency = 0.75,
    BackgroundColor3 = Color3.new(0, 1, 0),

    [OnEvent "MouseEnter"] = function()
        dropAction:set("completed")
    end,

    [OnEvent "MouseLeave"] = function()
        if dropAction:get(false) == "completed" then
            dropAction:set(nil) -- only clear this if it's not overwritten yet
        end
    end,

    [Children] = {
        New "UIListLayout" {
            SortOrder = "Name",
            Padding = UDim.new(0, 5)
        }
    }
}

-- Now we can write a mouse up handler to drop our items.

local mouseUpConn = UserInputService.InputEnded:Connect(function(inputObject)
    if inputObject.UserInputType ~= Enum.UserInputType.MouseButton1 then
        return
    end
    local dragInfo = currentlyDragging:get(false)
    if dragInfo == nil then
        return
    end
    local item = getTodoItemForID(dragInfo.id)
    local action = dropAction:get(false)
    if item ~= nil then
        if action == "incomplete" then
            item.completed:set(false)
        elseif action == "completed" then
            item.completed:set(true)
        end
    end
    currentlyDragging:set(nil)
end)

-- We'll need to construct an overlay frame for our items to live in while they
-- get dragged around.

local overlayFrame = New "Frame" {
    Size = UDim2.fromScale(1, 1),
    ZIndex = 10,
    BackgroundTransparency = 1
}

-- Let's construct the items themselves! Because we're constructing them at the
-- global level like this, they're only created and destroyed when they're added
-- and removed from the list.

local allEntries = ForValues(todoItems, function(item)
    return TodoEntry {
        Item = item,
        Parent = Computed(function()
            return if item.completed:get() then completedList else incompleteList
        end, Fusion.doNothing),
        OverlayFrame = overlayFrame
    }
end, Fusion.cleanup)

-- Finally, construct the whole UI :)

local ui = New "ScreenGui" {
    Parent = game:GetService("Players").LocalPlayer.PlayerGui,

    [Cleanup] = {
        mousePosConn,
        mouseUpConn
    },

    [Children] = {
        overlayFrame,
        incompleteList,
        completedList

        -- We don't have to pass `allEntries` in here - they manage their own
        -- parenting thanks to `Draggable` :)
    }
}
Back to top