# 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.