Tracks the scroll position of a viewport and renders its scrollbars.
This widget occupies only the one-column scrollbar strip at the right edge of the scrollable region and draws the track and thumb there. It does not render the content: children are laid out separately into the remaining width, and the container holds the offsets that position them.
The thumb position is a function of :content_height, :viewport_height and
:scroll_offset_y. Keep all three current — update/2 accepts them and
clamps the offsets to the content — or the thumb will not match what is on
screen. get_viewport/1 returns the current scroll state for child rendering.
Keyboard and mouse-wheel events scroll the viewport when focused.
Component tag
This module has no component_tag/0 and is not reached through the widget
registry. Drafter.App builds it as the element {:scrollable, children, opts}:
scrollable(children, opts)children are the elements to scroll. The renderer measures them, sets
:content_height from their combined preferred height and :viewport_height
and :viewport_width from the allocated rect, and re-measures on every pass —
so those three are computed for you and are not worth passing. A trailing
{:footer, _} child is split off and rendered outside the scrolled region.
Options
:content_height-non_neg_integer/0total content height in rows. Default0; set from the children's measured height through the element. Live-updatable.:content_width-non_neg_integer/0total content width in columns. Default0. Live-updatable; used only to clamp:scroll_offset_x.:viewport_height-pos_integer/0visible height in rows. Default10; set from the allocated rect through the element. Live-updatable.:viewport_width-pos_integer/0visible width in columns. Default80; set from the allocated rect less the scrollbar column through the element. Live-updatable.:show_vertical_scrollbar-:auto | :always | :never. Default:auto. Only:neverchanges anything: the scrollbar is drawn whenever the content is taller than the viewport and suppressed otherwise, so:alwaysbehaves like:auto. Mount-only.:show_horizontal_scrollbar-:auto | :always | :never. Default:never. Held on the state and never read; no horizontal scrollbar is drawn. Mount-only.:click_to_scroll-boolean/0. Defaultfalse. Held on the state and only consulted together with the internal:scroll_lockedflag, which nothing sets, so it has no effect on rendering. Clicking the track above or below the thumb always pages the viewport. Mount-only.:focusable-boolean/0. Defaulttrue. Held on the state and never read; the container is always focusable through its:focusabletrait. Mount-only.:child_widget_ids- list of widget IDs whose scroll events bubble through this container. Default[]. Live-updatable.:id- identifier for the container. Defaultnil; set by the renderer through the element. Mount-only.:focused-boolean/0initial focus state. Defaultfalse. Mount-only.
update/2 additionally accepts :scroll_offset_y and :scroll_offset_x to
drive the position directly; both are clamped to the content extent, as is any
offset already on the state, on every call. mount/1 always starts both at 0
and ignores any offset in the props.
Key bindings
up/down— one rowpage_up/page_down— one viewport heighthome/end— top or bottom
The mouse wheel moves three rows. Pressing the thumb starts a drag; pressing the track above or below it pages the viewport on release.
Usage
scrollable([label("row 1"), label("row 2")], show_vertical_scrollbar: :always)
Summary
Functions
The scroll state the renderer needs to position the children.
Handles the container's own events, replacing the dispatch use Drafter.Widget
would otherwise generate.
Builds the widget state from props.
Draws the one-column scrollbar into rect.
Callback implementation for Drafter.Widget.unmount/1.
Folds the measurements and offsets in props into state and re-clamps both
scroll offsets.
Types
@type t() :: %Drafter.Widget.ScrollableContainer{ child_widget_ids: [term()], click_to_scroll: boolean(), content_height: non_neg_integer(), content_width: non_neg_integer(), drag_thumb_offset: non_neg_integer(), dragging_scrollbar: boolean(), focusable: boolean(), focused: boolean(), hovering_scrollbar: boolean(), id: term(), scroll_locked: boolean(), scroll_offset_x: non_neg_integer(), scroll_offset_y: non_neg_integer(), show_horizontal_scrollbar: :auto | :always | :never, show_vertical_scrollbar: :auto | :always | :never, viewport_height: pos_integer(), viewport_width: pos_integer() }
@type viewport() :: %{ scroll_y: non_neg_integer(), scroll_x: non_neg_integer(), viewport_height: pos_integer(), viewport_width: pos_integer(), content_height: non_neg_integer(), content_width: non_neg_integer() }
Functions
The scroll state the renderer needs to position the children.
Returns :scroll_y, :scroll_x, :viewport_height, :viewport_width,
:content_height and :content_width.
iex> state = Drafter.Widget.ScrollableContainer.mount(%{content_height: 100})
iex> Drafter.Widget.ScrollableContainer.get_viewport(state)
%{scroll_y: 0, scroll_x: 0, viewport_height: 10, viewport_width: 80, content_height: 100, content_width: 0}
@spec handle_event(term(), t()) :: {:ok, t()} | {:ok, t(), list()} | {:bubble, t()} | {:noreply, t()}
Handles the container's own events, replacing the dispatch use Drafter.Widget
would otherwise generate.
Recognised events:
{:key, :up}/{:key, :down}- one row{:key, :page_up}/{:key, :page_down}- one viewport height{:key, :home}/{:key, :end}- top or bottom{:mouse, %{type: :scroll, direction: dir}}- three rows{:mouse, %{type: :mouse_down, x: 0, y: y}}- start a thumb drag whenyis on the thumb, otherwise{:noreply, state}; a press anywhere else bubbles{:mouse, %{type: :drag, y: y}}while dragging - move the viewport to match the pointer{:mouse, %{type: :mouse_up}}while dragging - end the drag{:mouse, %{type: :mouse_up, x: 0, y: y}}- page up or down whenyis off the thumb, otherwise bubble
Every other event returns {:bubble, state}. Anything that moves the viewport
returns {:ok, state, [:scroll_fast_render]}, except the home and end keys,
which return {:ok, state} and are not clamped against the content.
iex> state = Drafter.Widget.ScrollableContainer.mount(%{content_height: 100})
iex> {:ok, scrolled, actions} = Drafter.Widget.ScrollableContainer.handle_event({:key, :page_down}, state)
iex> {scrolled.scroll_offset_y, actions}
{10, [:scroll_fast_render]}
iex> state = Drafter.Widget.ScrollableContainer.mount(%{content_height: 100})
iex> {:ok, scrolled, _} = Drafter.Widget.ScrollableContainer.handle_event({:key, :up}, state)
iex> scrolled.scroll_offset_y
0
iex> state = Drafter.Widget.ScrollableContainer.mount(%{content_height: 100})
iex> {:ok, bottom} = Drafter.Widget.ScrollableContainer.handle_event({:key, :end}, state)
iex> bottom.scroll_offset_y
90
iex> state = Drafter.Widget.ScrollableContainer.mount(%{content_height: 100})
iex> Drafter.Widget.ScrollableContainer.handle_event({:key, :tab}, state) == {:bubble, state}
true
@spec mount(Drafter.Widget.props()) :: t()
Builds the widget state from props.
Both scroll offsets start at 0 whatever props says, and so do
:dragging_scrollbar, :hovering_scrollbar, :drag_thumb_offset and the
internal :scroll_locked flag.
iex> state = Drafter.Widget.ScrollableContainer.mount(%{})
iex> {state.content_height, state.viewport_height, state.viewport_width}
{0, 10, 80}
iex> state = Drafter.Widget.ScrollableContainer.mount(%{scroll_offset_y: 5})
iex> {state.scroll_offset_y, state.show_vertical_scrollbar, state.show_horizontal_scrollbar}
{0, :auto, :never}
@spec render(t(), Drafter.Widget.rect()) :: [Drafter.Draw.Strip.t()]
Draws the one-column scrollbar into rect.
Returns [] — no scrollbar at all — when the content fits the viewport or
:show_vertical_scrollbar is :never. Otherwise returns
min(rect.height, viewport_height) single-cell strips, with the thumb sized as
viewport_height * viewport_height / content_height, at least one row. The
content itself is drawn by the children, not here.
Callback implementation for Drafter.Widget.unmount/1.
@spec update(Drafter.Widget.props(), t()) :: t()
Folds the measurements and offsets in props into state and re-clamps both
scroll offsets.
Accepts :content_height, :content_width, :viewport_height,
:viewport_width, :child_widget_ids, :scroll_offset_y and
:scroll_offset_x, and drops every other key. :scroll_offset_y is clamped to
0..(content_height - viewport_height) and :scroll_offset_x to
0..(content_width - viewport_width), so shrinking the content pulls an
out-of-range offset back into view.
iex> state = Drafter.Widget.ScrollableContainer.mount(%{})
iex> updated = Drafter.Widget.ScrollableContainer.update(%{content_height: 100, scroll_offset_y: 999}, state)
iex> updated.scroll_offset_y
90
iex> state = Drafter.Widget.ScrollableContainer.mount(%{content_height: 100})
iex> updated = Drafter.Widget.ScrollableContainer.update(%{scroll_offset_y: 50}, state)
iex> Drafter.Widget.ScrollableContainer.update(%{content_height: 20}, updated).scroll_offset_y
10