signalis-eb/godot/addons/FreeControl/src/CustomClasses/Carousel/Carousel.gd
2025-06-17 01:26:30 -04:00

616 lines
24 KiB
GDScript

# Made by Xavier Alvarez. A part of the "FreeControl" Godot addon.
@tool
class_name Carousel extends Container
## A container for Carousel Display of [Control] nodes.
## Changes the behavior of how draging scrolls the carousel items. Also see [member snap_carousel_transtion_type], [member snap_carousel_ease_type], and [member paging_requirement].
enum SNAP_BEHAVIOR {
NONE = 0b00, ## No behavior.
SNAP = 0b01, ## Once drag is released, the carousel will snap to the nearest item.x
PAGING = 0b10 ## Carousel items will not scroll when dragged, unless [member paging_requirement] threshold is met. [member hard_stop] will be assumed as [code]true[/code] for this.
}
## Internel enum used to differentiate what animation is currently playing
enum ANIMATION_TYPE {
NONE = 0b00, ## No behavior.
MANUAL = 0b01, ## Currently animating via request by [method go_to_index].
SNAP = 0b10 ## Currently animating via an auto-item snapping request.
}
## This signal is emited when a snap reaches it's destination.
signal snap_end
## This signal is emited when a snap begins.
signal snap_begin
## This signal is emited when a drag finishes. This does not include the slowdown caused when [member hard_stop] is [code]false[/code].
signal drag_end
## This signal is emited when a drag begins.
signal drag_begin
## This signal is emited when the slowdown, caused when [member hard_stop] is [code]false[/code], finished naturally.
signal slowdown_end
## This signal is emited when the slowdown, caused when [member hard_stop] is [code]false[/code], is interrupted by another drag or other feature.
signal slowdown_interupted
@export_group("Carousel Options")
## The index of the item this carousel will start at.
@export var starting_index : int = 0:
set(val):
if starting_index != val:
starting_index = val
go_to_index(-val, false)
## The size of each item in the carousel.
@export var item_size : Vector2 = Vector2(200, 200):
set(val):
if item_size != val:
var current_index := get_carousel_index()
item_size = val
_settup_children()
go_to_index(current_index, false)
## The space between each item in the carousel.
@export var item_seperation : int = 0:
set(val):
if item_seperation != val:
item_seperation = val
_kill_animation()
_adjust_children()
## The orientation the carousel items will be displayed in.
@export_range(0, 360, 0.001, "or_less", "or_greater") var carousel_angle : float = 0.0:
set(val):
if carousel_angle != val:
var current_index := get_carousel_index()
carousel_angle = val
_angle_vec = Vector2.RIGHT.rotated(deg_to_rad(carousel_angle))
_kill_animation()
_adjust_children()
go_to_index(current_index, false)
@export_group("Loop Options")
## Allows looping from the last item to the first and vice versa.
@export var allow_loop : bool = true
## If [code]true[/code], the carousel will display it's items as if looping. Otherwise, the items will not loop.
## [br][br]
## also see [member enforce_border] and [member border_limit].
@export var display_loop : bool = true:
set(val):
if val != display_loop:
display_loop = val
_adjust_children()
notify_property_list_changed()
## The number of items, surrounding the current item of the current index, that will be visible.
## If [code]-1[/code], all items will be visible.
@export var display_range : int = -1:
set(val):
val = max(-1, val)
if val != display_range:
display_range = val
_adjust_children()
@export_group("Snap")
## Assigns the behavior of how draging scrolls the carousel items. Also see [member snap_carousel_transtion_type], [member snap_carousel_ease_type], and [member paging_requirement].
@export var snap_behavior : SNAP_BEHAVIOR = SNAP_BEHAVIOR.SNAP:
set(val):
if val != snap_behavior:
snap_behavior = val
_end_drag_slowdown()
_create_animation(get_carousel_index(), ANIMATION_TYPE.SNAP)
notify_property_list_changed()
## If [member snap_behavior] is [SNAP_BEHAVIOR.PAGING], this is the draging threshold needed to page to the next carousel item.
@export var paging_requirement : int = 200:
set(val):
val = max(1, val)
if val != paging_requirement:
paging_requirement = val
_adjust_children()
@export_group("Animation Options")
@export_subgroup("Manual")
## The duration of the animation any call to [method go_to_index] will cause, if the animation option is requested.
@export_range(0.001, 2.0, 0.001, "or_greater") var manual_carousel_duration : float = 0.4
## The [enum Tween.TransitionType] of the animation any call to [method go_to_index] will cause, if the animation option is requested.
@export var manual_carousel_transtion_type : Tween.TransitionType
## The [enum Tween.EaseType] of the animation any call to [method go_to_index] will cause, if the animation option is requested.
@export var manual_carousel_ease_type : Tween.EaseType
@export_subgroup("Snap")
## The duration of the animation when snapping to an item.
@export_range(0.001, 2.0, 0.001, "or_greater") var snap_carousel_duration : float = 0.2
## The [enum Tween.TransitionType] of the animation when snapping to an item.
@export var snap_carousel_transtion_type : Tween.TransitionType
## The [enum Tween.EaseType] of the animation when snapping to an item.
@export var snap_carousel_ease_type : Tween.EaseType
@export_group("Drag")
## If [code]true[/code], the user is allowed to drag via their mouse or touch.
@export var can_drag : bool = true:
set(val):
if val != can_drag:
can_drag = val
notify_property_list_changed()
if !val:
_drag_scroll_value = 0
if _is_dragging:
_adjust_children()
## If [code]true[/code], the user is allowed to drag outisde the drawer's bounding box.
## [br][br]
## Also see [member can_drag].
@export var drag_outside : bool = false:
set(val):
if val != drag_outside:
drag_outside = val
@export_subgroup("Limits")
## The max amount a user can drag in either direction. If [code]0[/code], then the user can drag any amount they wish.
@export var drag_limit : int = 0:
set(val):
val = max(0, val)
if val != drag_limit: drag_limit = val
## When dragging, the user will not be able to move past the last or first item, besides for [member border_limit] number of extra pixels.
## [br][br]
## This value is assumed [code]false[/code] is [member display_loop] is [code]true[/code].
@export var enforce_border : bool = false:
set(val):
if val != enforce_border:
enforce_border = val
_adjust_children()
notify_property_list_changed()
## The amount of extra pixels a user can drag past the last and before the first item in the carousel.
## [br][br]
## This property does nothing if enforce_border is [code]false[/code].
@export var border_limit : int = 0:
set(val):
if val != border_limit:
border_limit = val
_adjust_children()
@export_subgroup("Slowdown")
## If [code]true[/code] the carousel will immediately stop when not being dragged. Otherwise, drag speed will be gradually decreased.
## [br][br]
## This property is assumed [code]true[/code] if [member snap_behavior] is set to [SNAP_BEHAVIOR.PAGING]. Also see [member slowdown_drag], [member slowdown_friction], and [member slowdown_cutoff].
@export var hard_stop : bool = true:
set(val):
if val != hard_stop:
hard_stop = val
_end_drag_slowdown()
notify_property_list_changed()
## The percentage multiplier the drag velocity will experience each frame.
## [br][br]
## This property does nothing if [member hard_stop] is [code]true[/code].
@export_range(0.0, 1.0, 0.001) var slowdown_drag : float = 0.9
## The constant decrease the drag velocity will experience each frame.
## [br][br]
## This property does nothing if [member hard_stop] is [code]true[/code].
@export_range(0.0, 5.0, 0.001, "or_greater", "hide_slider") var slowdown_friction : float = 0.1
## The cutoff amount. If drag velocity magnitude drops below this amount, the slowdown has finished.
## [br][br]
## This property does nothing if [member hard_stop] is [code]true[/code].
@export_range(0.01, 10.0, 0.001, "or_greater", "hide_slider") var slowdown_cutoff : float = 0.01
var _scroll_value : int
var _drag_scroll_value : int
var _drag_velocity : float
var _item_count : int = 0
var _item_infos : Array
var _scroll_tween : Tween
var _is_dragging : bool = false
var _last_animation : ANIMATION_TYPE = ANIMATION_TYPE.NONE
var _angle_vec : Vector2
var _mouse_checking : bool
# Public Functions
## Gets the index of the current carousel item.[br]
## If [param with_drag] is [code]true[/code] the current drag will also be considered.[br]
## If [param with_clamp] is [code]true[/code] the index will be looped if [member allow_loop] is true or clamped to a vaild index within the carousel.
func get_carousel_index(with_drag : bool = false, with_clamp : bool = true) -> int:
if _item_count == 0: return -1
var scroll : int = _scroll_value
if with_drag: scroll += _drag_scroll_value
var calculated := floori((float(scroll) / float(_get_relevant_axis())) + 0.5)
if with_clamp:
if allow_loop:
calculated = posmod(calculated, _item_count)
else:
calculated = clampi(calculated, 0, _item_count - 1)
return calculated
## Moves to an item of the given index within the carousel. If an invalid index is given, it will be posmod into a vaild index.
func go_to_index(idx : int, animation : bool = true) -> void:
if _item_count == 0: return
if allow_loop:
idx = (((idx % _item_count) - _item_count) % _item_count)
else:
idx = clamp(idx, 0, _item_count - 1)
if animation:
_create_animation(idx, ANIMATION_TYPE.MANUAL)
else:
_kill_animation()
_scroll_value = -_get_relevant_axis() * idx
_adjust_children()
## Moves to the previous item in the carousel, if there is one.
func prev(animation : bool = true) -> void:
go_to_index(get_carousel_index() - 1, animation)
## Moves to the next item in the carousel, if there is one.
func next(animation : bool = true) -> void:
go_to_index(get_carousel_index() + 1, animation)
## Enacts a manual drag on the carousel. This can be used even if [member can_drag] is [code]false[/code].
## Note that [param from] and [param dir] are considered in local coordinates.
## [br][br]
## Is not affected by [member hard_stop], [member drag_outside], and [member drag_limit].
func flick(from : Vector2, dir : Vector2) -> void:
drag_begin.emit()
_kill_animation()
_end_drag_slowdown()
_handle_drag_angle(dir - from)
_on_drag_release()
## Returns if the carousel is currening scrolling via na animation
func is_animating() -> bool:
return _scroll_tween.is_running()
## Returns if the carousel is currening being dragged by player input.
func being_dragged() -> bool:
return _is_dragging
## Returns the current scroll value.
func get_scroll(with_drag : bool = false) -> int:
if with_drag:
return _scroll_value + _drag_scroll_value
return _scroll_value
## Returns the current number of items in the carousel
func get_item_count() -> int:
return _item_count
# Virtual Functions
## A virtual function that is is called whenever the scroll changes.
func _on_progress(scroll : int) -> void: pass
## A virtual function that is is called whenever the scroll changes, for each visible item in the carousel
func _on_item_progress(item : Control, local_scroll : int, scroll : int, local_index : int, index : int) -> void: pass
func _handle_drag_angle(local_pos : Vector2) -> void:
var projected_scalar : float = -local_pos.dot(_angle_vec) / _angle_vec.length_squared()
_drag_velocity = projected_scalar
_drag_scroll_value += projected_scalar
if drag_limit != 0:
_drag_scroll_value = clampi(_drag_scroll_value, -drag_limit, drag_limit)
if snap_behavior == SNAP_BEHAVIOR.PAGING:
if paging_requirement < _drag_scroll_value:
_drag_scroll_value = 0
var desired := get_carousel_index() + 1
if allow_loop || desired < _item_count:
_create_animation(desired, ANIMATION_TYPE.SNAP)
elif -paging_requirement > _drag_scroll_value:
_drag_scroll_value = 0
var desired := get_carousel_index() - 1
if allow_loop || desired >= 0:
_create_animation(desired, ANIMATION_TYPE.SNAP)
else:
_adjust_children()
# Private Functions
func _get_child_rect(child : Control) -> Rect2:
var child_pos : Vector2 = (size - item_size) * 0.5
var child_size : Vector2
child_size = child.get_combined_minimum_size()
match child.size_flags_horizontal:
SIZE_FILL: child_size.x = item_size.x
SIZE_SHRINK_BEGIN: pass
SIZE_SHRINK_CENTER: child_pos.x += (item_size.x - child_size.x) * 0.5
SIZE_SHRINK_END: child_pos.x += (item_size.x - child_size.x)
match child.size_flags_vertical:
SIZE_FILL: child_size.y = item_size.y
SIZE_SHRINK_BEGIN: pass
SIZE_SHRINK_CENTER: child_pos.y += (item_size.y - child_size.y) * 0.5
SIZE_SHRINK_END: child_pos.y += (item_size.y - child_size.y)
child.size = child_size
child.position = child_pos
return Rect2(child_pos, child_size)
func _get_control_children() -> Array[Control]:
var ret : Array[Control]
ret.assign(get_children().filter(func(child : Node): return child is Control && child.visible))
return ret
func _get_relevant_axis() -> int:
var abs_angle_vec = _angle_vec.abs()
if abs_angle_vec.y >= abs_angle_vec.x:
return (item_size.x / abs_angle_vec.y) + item_seperation
return (item_size.y / abs_angle_vec.x) + item_seperation
func _get_adjusted_scroll() -> int:
var scroll := _scroll_value
if snap_behavior != SNAP_BEHAVIOR.PAGING:
scroll += _drag_scroll_value
if enforce_border:
scroll = clampi(scroll, -border_limit, _get_relevant_axis() * (_item_count - 1) + border_limit)
elif display_loop:
scroll = posmod(scroll, _get_relevant_axis() * _item_count)
return scroll
func _create_animation(idx : int, animation_type : ANIMATION_TYPE) -> void:
_kill_animation()
if _item_count == 0: return
_scroll_tween = create_tween()
var axis := _get_relevant_axis()
var max_scroll := axis * _item_count
if max_scroll == 0: max_scroll = 1
var desired_scroll := posmod(axis * idx, max_scroll)
if allow_loop && display_loop:
_scroll_value = posmod(_scroll_value, max_scroll)
if abs(_scroll_value - desired_scroll) > (max_scroll >> 1):
var left_distance := posmod(_scroll_value - desired_scroll, max_scroll)
var right_distance := posmod(desired_scroll - _scroll_value, max_scroll)
if left_distance < right_distance:
desired_scroll -= max_scroll
else:
desired_scroll += max_scroll
_last_animation = animation_type
match animation_type:
ANIMATION_TYPE.MANUAL:
_scroll_tween.set_ease(manual_carousel_ease_type)
_scroll_tween.set_trans(manual_carousel_transtion_type)
_scroll_tween.tween_method(
_animation_method,
_scroll_value,
desired_scroll,
manual_carousel_duration)
ANIMATION_TYPE.SNAP:
snap_begin.emit()
_scroll_tween.set_ease(snap_carousel_ease_type)
_scroll_tween.set_trans(snap_carousel_transtion_type)
_scroll_tween.tween_method(
_animation_method,
_scroll_value,
desired_scroll,
snap_carousel_duration)
_scroll_tween.tween_callback(_kill_animation)
_scroll_tween.play()
func _animation_method(scroll : int) -> void:
_scroll_value = scroll
_adjust_children()
func _kill_animation() -> void:
if _last_animation == ANIMATION_TYPE.SNAP:
snap_end.emit()
_last_animation = ANIMATION_TYPE.NONE
if _scroll_tween && _scroll_tween.is_running():
_scroll_tween.kill()
func _sort_children() -> void:
_settup_children()
_adjust_children()
func _settup_children() -> void:
var children : Array[Control] = _get_control_children()
_item_count = children.size()
_item_infos.resize(_item_count)
for i : int in range(0, _item_count):
var item_info := ItemInfo.new()
item_info.node = children[i]
item_info.rect = _get_child_rect(children[i])
_item_infos[i] = item_info
func _adjust_children() -> void:
if _item_count == 0: return
var range : Array
var max_local_offset : int
var children : Array[Control] = _get_control_children()
var axis := _get_relevant_axis()
var scroll := _get_adjusted_scroll()
var index : int
var local_scroll : int
var adjustment : int
if axis == 0:
index = 0
local_scroll = 0
adjustment = 0
else:
index = int(scroll / axis)
local_scroll = fposmod(scroll, axis)
adjustment = int(scroll < 0)
index += adjustment & int(local_scroll == 0)
_on_progress(scroll)
if display_loop:
if display_range == -1:
max_local_offset = (_item_count >> 1)
range = range(0, _item_count)
for i : int in range:
_item_infos[i].loaded = false
else:
max_local_offset = (display_range >> 1)
range = range(0, (display_range << 1) + 1)
for i : int in range(0, _item_count):
var item_info : ItemInfo = _item_infos[i]
item_info.loaded = false
item_info.node.visible = false
for item : int in range:
var local_offset := (item >> 1) * (((item & 1) << 1) - 1) + (item & 1)
var local_index := posmod(index + local_offset, _item_count)
var item_info : ItemInfo = _item_infos[local_index]
if item_info.loaded: break
local_offset += adjustment
var rect : Rect2 = item_info.rect
rect.position += _angle_vec * (local_offset * axis - local_scroll)
fit_child_in_rect(item_info.node, rect)
item_info.loaded = true
item_info.node.visible = true
item_info.node.z_index = max_local_offset - abs(local_offset)
_on_item_progress(item_info.node, local_scroll, scroll, item, local_index)
else:
if display_range == -1:
max_local_offset = (_item_count >> 1) + (_item_count & 1) + 1
range = range(0, _item_count)
else:
max_local_offset = display_range
range = range(max(0, index - display_range), min(_item_count, index + display_range + 1))
for info : ItemInfo in _item_infos:
info.node.visible = false
for item : int in range:
var local_index := item - index
var item_info : ItemInfo = _item_infos[item]
local_index += adjustment
var rect : Rect2 = item_info.rect
rect.position += _angle_vec * (local_index * axis - local_scroll)
fit_child_in_rect(item_info.node, rect)
item_info.node.visible = true
item_info.node.z_index = max_local_offset - abs(local_index)
_on_item_progress(item_info.node, local_scroll, scroll, item, local_index)
func _start_drag_slowdown() -> void:
if is_inside_tree() && !get_tree().process_frame.is_connected(_handle_drag_slowdown):
get_tree().process_frame.connect(_handle_drag_slowdown)
func _end_drag_slowdown() -> void:
if abs(_drag_velocity) < slowdown_cutoff:
slowdown_interupted.emit()
_drag_velocity = 0
if snap_behavior == SNAP_BEHAVIOR.SNAP:
_create_animation(get_carousel_index(), ANIMATION_TYPE.SNAP)
if is_inside_tree() && get_tree().process_frame.is_connected(_handle_drag_slowdown):
get_tree().process_frame.disconnect(_handle_drag_slowdown)
func _handle_drag_slowdown() -> void:
if abs(_drag_velocity) < slowdown_cutoff:
slowdown_end.emit()
_end_drag_slowdown()
return
if _drag_velocity > 0:
_drag_velocity = max(0, _drag_velocity - slowdown_friction)
else:
_drag_velocity = min(0, _drag_velocity + slowdown_friction)
_drag_velocity *= slowdown_drag
_scroll_value += _drag_velocity
_adjust_children()
func _init() -> void:
sort_children.connect(_sort_children)
tree_exiting.connect(_end_drag_slowdown)
mouse_exited.connect(_mouse_check)
_angle_vec = Vector2.RIGHT.rotated(deg_to_rad(carousel_angle))
func _ready() -> void:
_settup_children()
if _item_count > 0:
starting_index = posmod(starting_index, _item_count)
go_to_index(-starting_index, false)
func _validate_property(property: Dictionary) -> void:
if property.name == "enforce_border":
if display_loop:
property.usage |= PROPERTY_USAGE_READ_ONLY
elif property.name == "border_limit":
if display_loop || !enforce_border:
property.usage |= PROPERTY_USAGE_READ_ONLY
elif property.name == "paging_requirement":
if snap_behavior != SNAP_BEHAVIOR.PAGING:
property.usage |= PROPERTY_USAGE_READ_ONLY
elif property.name == "hard_stop":
if snap_behavior == SNAP_BEHAVIOR.PAGING:
property.usage |= PROPERTY_USAGE_READ_ONLY
elif property.name in ["slowdown_drag", "slowdown_friction", "slowdown_cutoff"]:
if hard_stop || snap_behavior == SNAP_BEHAVIOR.PAGING:
property.usage |= PROPERTY_USAGE_READ_ONLY
elif property.name in ["drag_outside"]:
if !can_drag:
property.usage |= PROPERTY_USAGE_READ_ONLY
func _gui_input(event: InputEvent) -> void:
if event is not InputEventMouse:
return
var has_point := get_viewport_rect().has_point(event.position)
if (
(event is InputEventScreenDrag || event is InputEventMouseMotion) &&
(drag_outside || has_point)
):
if event.pressure == 0:
if _is_dragging: _on_drag_release()
return
if !_is_dragging && has_point:
drag_begin.emit()
_mouse_checking = true
_end_drag_slowdown()
_kill_animation()
_is_dragging = true
_handle_drag_angle(event.relative)
elif (event is InputEventScreenTouch || event is InputEventMouseButton):
if !event.pressed: _on_drag_release()
func _on_drag_release() -> void:
_mouse_checking = false
_is_dragging = false
drag_end.emit()
if snap_behavior != SNAP_BEHAVIOR.PAGING:
_scroll_value = _get_adjusted_scroll()
_drag_scroll_value = 0
if snap_behavior == SNAP_BEHAVIOR.NONE:
if !hard_stop: _start_drag_slowdown()
elif snap_behavior == SNAP_BEHAVIOR.SNAP:
if hard_stop: _create_animation(get_carousel_index(), ANIMATION_TYPE.SNAP)
else: _start_drag_slowdown()
func _mouse_check() -> void:
if _mouse_checking:
_on_drag_release()
func _get_allowed_size_flags_horizontal() -> PackedInt32Array:
return [SIZE_FILL, SIZE_SHRINK_BEGIN, SIZE_SHRINK_CENTER, SIZE_SHRINK_END]
func _get_allowed_size_flags_vertical() -> PackedInt32Array:
return [SIZE_FILL, SIZE_SHRINK_BEGIN, SIZE_SHRINK_CENTER, SIZE_SHRINK_END]
# Used to hold data about a carousel item
class ItemInfo:
var node : Control
var rect : Rect2
var loaded : bool
# Made by Xavier Alvarez. A part of the "FreeControl" Godot addon.