defmodule FloUI.Scrollable.Container do @moduledoc """ ## Usage in SnapFramework Scrollable container is used for large content that you want to scroll. It renders a component child within. this is meant to be as plug and play as possible. With minimal fiddling to get it to work. You can use the ScrollBar component directly if you want to build your own scrollable containers. data is an object in the form of ``` elixir %{ frame: {460, 470}, content: {800, 800} } ``` You can choose which scroll bars to render via the `scroll_bars` option. ``` elixir <%= graph font_size: 20 %> <%= component FloUI.Scrollable.Container, %{ frame: {460, 470}, content: {800, 800} }, translate: {20, 60}, scroll_bars: %{ vertical: %{ show: true, show_buttons: true, theme: Scenic.Primitive.Style.Theme.preset(:dark), thickness: 15 }, horizontal: %{ show: true, show_buttons: true, theme: Scenic.Primitive.Style.Theme.preset(:dark), thickness: 15 } } do %> <%= component Basic.Component.Page4, nil %> <% end %> ``` """ alias Scenic.Graph alias Scenic.Math.Vector2 alias FloUI.Scrollable.Hotkeys alias FloUI.Scrollable.Direction alias FloUI.Scrollable.Acceleration alias FloUI.Scrollable.PositionCap use SnapFramework.Component, name: :scrollable_container, template: "lib/scrollable/container.eex", controller: FloUI.Scrollable.ScrollableContainerController, assigns: [], opts: [] defcomponent(:scrollable_container, :map) @default_horizontal_scroll_bar %{ show: false, show_buttons: false, thickness: 15, radius: 3, theme: FloUI.Theme.preset(:scrollbar) } @default_vertical_scroll_bar %{ show: true, show_buttons: true, thickness: 15, radius: 3, theme: FloUI.Theme.preset(:scrollbar) } @default_position {0, 0} @default_fps 30 use_effect([assigns: [scroll_position: :any]], run: [:on_scroll_position_change] ) @impl true def setup(%{assigns: %{data: data, opts: opts}} = scene) do {content_width, content_height} = data.content {frame_width, frame_height} = data.frame {frame_x, frame_y} = opts[:translate] || @default_position scroll_position = Map.get(data, :scroll_position, {0, 0}) scroll_bars = case opts[:scroll_bars] do nil -> %{vertical: @default_vertical_scroll_bar, horizontal: @default_horizontal_scroll_bar} scroll_bars -> vertical = Map.get(scroll_bars, :vertical, @default_vertical_scroll_bar) horizontal = Map.get(scroll_bars, :horizontal, @default_horizontal_scroll_bar) %{ vertical: %{ show: Map.get(vertical, :show, true), show_buttons: Map.get(vertical, :show_buttons, true), thickness: Map.get(vertical, :thickness, 15), radius: Map.get(vertical, :radius, 3), theme: Map.get(vertical, :theme, FloUI.Theme.preset(:scrollbar)) }, horizontal: %{ show: Map.get(horizontal, :show, true), show_buttons: Map.get(horizontal, :show_buttons, true), thickness: Map.get(horizontal, :thickness, 15), radius: Map.get(horizontal, :radius, 3), theme: Map.get(horizontal, :theme, FloUI.Theme.preset(:scrollbar)) } } end assign(scene, id: opts[:id] || :scrollable, theme: opts[:theme] || FloUI.Theme.preset(:scrollbar), frame: %{x: frame_x, y: frame_y, width: frame_width, height: frame_height}, content: %{x: 0, y: 0, width: content_width, height: content_height}, scroll_position: scroll_position, fps: opts[:scroll_fps] || @default_fps, acceleration: Acceleration.init(opts[:scroll_acceleration]), hotkeys: Hotkeys.init(opts[:scroll_hotkeys]), scroll_direction: nil, scroll_bars_state: %{ vertical: %{ scrolling: :idle, wheel_state: nil, scroll_buttons: %{ scroll_button_1: :released, scroll_button_2: :released }, pid: nil }, horizontal: %{ scrolling: :idle, wheel_state: nil, scroll_buttons: %{ scroll_button_1: :released, scroll_button_2: :released }, pid: nil } }, scroll_bars: scroll_bars ) |> init_position_caps end @impl true def mounted(scene) do scene = FloUI.Scrollable.ScrollableContainerController.render_content(scene) Scenic.Scene.push_graph(scene, scene.assigns.graph) end @impl true def bounds(%{frame: {x, y}}, _opts) do {0.0, 0.0, x, y} end @impl true def process_update(data, _opts, scene) do scene = assign(scene, data: data, scroll_position: PositionCap.cap(scene.assigns.position_caps, Vector2.invert(data.scroll_position)) ) {:noreply, scene} end @impl true def process_event( {:register_scroll_bar, direction, scroll_bar_state}, _pid, %{assigns: %{scroll_bars_state: scroll_bars_state}} = scene ) do scene = scene |> assign( scroll_bars_state: Map.update!(scroll_bars_state, direction, fn _ -> scroll_bar_state end) ) {:noreply, scene} end def process_event({:scroll_bar_state_changed, direction, scroll_bar_state}, _, scene) do scene = scene |> assign( scroll_bars_state: Map.update!( scene.assigns.scroll_bars_state, direction, fn _ -> scroll_bar_state end) ) |> update {:noreply, scene} end def process_event({:update_scroll_position, :vertical, {_, y}}, _, scene) do {x, _} = scene.assigns.scroll_position {:noreply, assign(scene, scroll_position: PositionCap.cap(scene.assigns.position_caps, {x, y}))} end def process_event({:update_scroll_position, :horizontal, {_, x}}, _, scene) do {_, y} = scene.assigns.scroll_position {:noreply, assign(scene, scroll_position: PositionCap.cap(scene.assigns.position_caps, {x, y}))} end def process_event({:update_scroll_position, pos}, _, scene) do {:noreply, assign(scene, scroll_position: PositionCap.cap(scene.assigns.position_caps, pos))} end def process_event(event, _, scene) do {:cont, event, scene} end @impl true def process_input( {:cursor_scroll, scroll_pos}, :input_capture, %{assigns: %{scroll_bars_state: scroll_bars_state}} = scene ) do if not is_nil(scroll_bars_state.vertical.pid) do GenServer.cast(scroll_bars_state.vertical.pid, {:update_cursor_scroll, scroll_pos}) end if not is_nil(scroll_bars_state.horizontal.pid) do GenServer.cast(scroll_bars_state.horizontal.pid, {:update_cursor_scroll, scroll_pos}) end {:noreply, scene} end @impl true def process_info(:tick, scene) do {:noreply, assign(scene, animating: false) |> update} end @spec init_position_caps(Scenic.Scene.t) :: Scenic.Scene.t defp init_position_caps( %{ assigns: %{ frame: %{width: frame_width, height: frame_height}, content: %{x: x, y: y, width: content_width, height: content_height} } } = scene ) do min = {x + frame_width - content_width, y + frame_height - content_height} max = {x, y} position_cap = PositionCap.init(%{min: min, max: max}) assign(scene, position_caps: position_cap, scroll_position: PositionCap.cap(position_cap, Vector2.invert(scene.assigns.scroll_position)) ) end @spec update(Scenic.Scene.t) :: Scenic.Scene.t defp update(scene) do scene |> apply_force |> verify_cooling_down |> tick end @spec verify_cooling_down(Scenic.Scene.t) :: Scenic.Scene.t defp verify_cooling_down(%{assigns: %{scroll_bars_state: %{vertical: vertical, horizontal: horizontal}}} = scene) do if vertical.scrolling == :idle and horizontal.scrolling == :idle and not Acceleration.is_stationary?(scene.assigns.acceleration) do assign(scene, scroll_bars_state: %{ vertical: %{vertical | scrolling: :cooling_down}, horizontal: %{horizontal | scrolling: :cooling_down} } ) else if vertical.scrolling == :cooling_down and horizontal.scrolling == :cooling_down and Acceleration.is_stationary?(scene.assigns.acceleration) do assign(scene, scroll_bars_state: %{ vertical: %{vertical | scrolling: :idle}, horizontal: %{horizontal | scrolling: :idle} } ) else scene end end end @spec apply_force(Scenic.Scene.t) :: Scenic.Scene.t defp apply_force( %{ assigns: %{ scroll_bars_state: %{ vertical: %{ scrolling: :idle }, horizontal: %{ scrolling: :idle } } } } = scene ), do: scene defp apply_force( %{ assigns: %{ scroll_bars_state: %{ vertical: %{ scrolling: vert_scroll, wheel_state: %{offset: {_, offset_y}} }, horizontal: %{ scrolling: horiz_scroll, wheel_state: %{offset: {_, offset_x}} } } } } = scene ) when vert_scroll == :wheel or horiz_scroll == :wheel do {x, y} = scene.assigns.scroll_position scroll_position = {x + offset_x * 5, y + offset_y * 5} assign(scene, scroll_position: PositionCap.cap(scene.assigns.position_caps, scroll_position)) end defp apply_force(scene) do scroll_direction = get_scroll_direction(scene) force = Hotkeys.direction(scene.assigns.hotkeys) |> Vector2.add(scroll_direction) Acceleration.apply_force(scene.assigns.acceleration, force) |> Acceleration.apply_counter_pressure() |> (&assign(scene, acceleration: &1)).() |> (fn scene -> scroll_pos = Acceleration.translate(scene.assigns.acceleration, scene.assigns.scroll_position) assign(scene, scroll_position: PositionCap.cap(scene.assigns.position_caps, scroll_pos)) end).() end @spec get_scroll_direction(Scenic.Scene.t) :: Scenic.Math.Vector2.t defp get_scroll_direction(%{assigns: %{scroll_bars_state: scroll_bars_state}}) do case scroll_bars_state do %{vertical: %{scroll_buttons: %{scroll_button_1: :pressed, scroll_button_2: :released}}} -> Direction.return(1, :vertical) |> Direction.to_vector_2() %{vertical: %{scroll_buttons: %{scroll_button_1: :released, scroll_button_2: :pressed}}} -> Direction.return(-1, :vertical) |> Direction.to_vector_2() %{horizontal: %{scroll_buttons: %{scroll_button_1: :pressed, scroll_button_2: :released}}} -> Direction.return(1, :horizontal) |> Direction.to_vector_2() %{horizontal: %{scroll_buttons: %{scroll_button_1: :released, scroll_button_2: :pressed}}} -> Direction.return(-1, :horizontal) |> Direction.to_vector_2() _ -> {0, 0} end end @spec tick(Scenic.Scene.t) :: Scenic.Scene.t defp tick(%{assigns: %{scroll_bars_state: %{vertical: %{scrolling: :idle}, horizontal: %{scrolling: :idle}}}} = scene), do: assign(scene, animating: false) defp tick(%{assigns: %{scroll_bars_state: %{vertical: %{scrolling: :dragging}}}} = scene) do assign(scene, animating: false) end defp tick(%{assigns: %{scroll_bars_state: %{horizontal: %{scrolling: :dragging}}}} = scene) do assign(scene, animating: false) end defp tick(%{assigns: %{scroll_bars_state: %{vertical: %{scrolling: vert_scrolling}, horizontal: %{scrolling: horiz_scrolling}}}} = scene) when vert_scrolling == :wheel or horiz_scrolling == :wheel do assign(scene, animating: false) end defp tick(%{assigns: %{animating: true}} = scene), do: scene defp tick(scene) do Process.send_after(self(), :tick, tick_time(scene)) assign(scene, animating: true) end @spec tick_time(Scenic.Scene.t) :: integer defp tick_time(%{assigns: %{fps: fps}}) do trunc(1000 / fps) end end