ScrollableContainer: add support for step/grid scrolling

When the containee is row-based, this can ensure that when
scrolling with swipes, we get the a full row at top, and
that any truncated row at top or bottom is fully visible
after a swipe.
pull/10440/head
poire-z 1 year ago
parent 0ee10e5049
commit 7a95d11f07

@ -21,6 +21,7 @@ local InputContainer = require("ui/widget/container/inputcontainer")
local Math = require("optmath")
local UIManager = require("ui/uimanager")
local VerticalScrollBar = require("ui/widget/verticalscrollbar")
local Input = Device.input
local Screen = Device.screen
local logger = require("logger")
@ -29,6 +30,35 @@ local ScrollableContainer = InputContainer:extend{
ignore_events = nil,
scroll_bar_width = Screen:scaleBySize(6),
-- Scroll behaviour
-- If true, swipe a full visible width or height no matter the swipe distance
swipe_full_view = true,
-- Array of rows info: if provided, swipe will align the top of the view on
-- a row, and ensure any truncated row at top or bottom gets fully visible
-- after the swipe.
-- Each array element (a row) must contain:
-- top = y of the top of a row
-- bottom = y of the bottom of a row (included, no overlap with 'top' of next row)
-- It may contain:
-- content_top = y of the content top of a row
-- content_bottom = y of the content bottom of a row (included)
-- that should not account for any top or bottom padding (which should be accounted in
-- top/bottom), which will be used instead of top/bottom when looking for truncated rows.
-- The disctinction allows, if only some top or bottom padding is truncated, but not the
-- content, to consider it fully visible and to not need to be visible after the swipe,
-- but to still use these padding for the alignments.
step_scroll_grid = nil, -- either this array
step_scroll_grid_func = nil, -- or a function returning this array
-- Not implemented, but could be when this behaviour is needed on the x-axis:
-- each row element could contain an array with the same kind of info (left,
-- right, content_left, content_right) for its horizontal components, so
-- swiping horizontally can "step" on those of the row at top.
-- If true, don't draw a truncated row at bottom (we currently let a truncated row
-- at top be shown).
hide_truncated_grid_items = false,
-- Set to true if child widget is larger, false otherwise
_is_scrollable = nil,
-- Current scroll offset (use getScrolledOffset()/setScrolledOffset() to access them)
@ -46,9 +76,10 @@ local ScrollableContainer = InputContainer:extend{
_h_scroll_bar = nil,
-- Scratch buffer
_bb = nil,
_crop_dx = 0,
_crop_w = nil,
_crop_h = nil,
_crop_dx = 0,
_crop_h_limited = nil,
}
function ScrollableContainer:getScrollbarWidth(scroll_bar_width)
@ -60,19 +91,19 @@ function ScrollableContainer:getScrollbarWidth(scroll_bar_width)
end
function ScrollableContainer:init()
-- Unflatten self.ignore_events to table keys for cleaner code below
local ignore = {}
if self.ignore_events then
for _, evname in pairs(self.ignore_events) do
ignore[evname] = true
end
end
if Device:isTouchDevice() then
local range = Geom:new{
x = 0, y = 0,
w = Screen:getWidth(),
h = Screen:getHeight(),
}
-- Unflatten self.ignore_events to table keys for cleaner code below
local ignore = {}
if self.ignore_events then
for _, evname in pairs(self.ignore_events) do
ignore[evname] = true
end
end
-- The following gestures need to be supported, depending on the
-- ways a user can move/scroll things:
-- Hold happens if he holds at start
@ -89,6 +120,12 @@ function ScrollableContainer:init()
ScrollablePanRelease = not ignore.pan_release and { GestureRange:new{ ges = "pan_release", range = range } } or nil,
}
end
if Device:hasKeys() then
self.key_events = {
ScrollPageUp = not ignore.key_pg_back and { { Input.group.PgBack } } or nil,
ScrollPageDown = not ignore.key_pg_fwd and { { Input.group.PgFwd } } or nil,
}
end
end
function ScrollableContainer:initState()
@ -145,6 +182,14 @@ function ScrollableContainer:initState()
self._crop_dx = self.dimen.w - self._crop_w
end
end
if self.step_scroll_grid_func then
self.step_scroll_grid = self.step_scroll_grid_func()
end
if self.step_scroll_grid then
-- Ensure we anchor on the scroll step grid
self:_scrollBy(0, 0, true)
end
self:_hideTruncatedGridItemsIfRequested()
self:_updateScrollBars()
end
end
@ -197,24 +242,136 @@ function ScrollableContainer:scrollToRatio(ratio_x, ratio_y)
self:_scrollBy(0, 0) -- get the additional work done
end
function ScrollableContainer:_scrollBy(dx, dy)
function ScrollableContainer:_getStepScrollRowAtY(y, check_below)
for _, row in ipairs(self.step_scroll_grid) do
if y >= row.top and y <= row.bottom then
if check_below then
-- return row, is row fully below y, is its content fully below y
return row, y == row.top, y <= (row.content_top or row.top)
else
-- return row, is row fully above y, is its content fully above y
return row, y == row.bottom, y >= (row.content_bottom or row.bottom)
end
end
end
end
function ScrollableContainer:_hideTruncatedGridItemsIfRequested()
self._crop_h_limited = nil
if self.hide_truncated_grid_items and self.step_scroll_grid then
local new_bottom_row, new_bottom_row_fully_visible = self:_getStepScrollRowAtY(self._scroll_offset_y + self._crop_h - 1, false)
if new_bottom_row and not new_bottom_row_fully_visible then
self._crop_h_limited = new_bottom_row.top - self._scroll_offset_y
end
end
end
function ScrollableContainer:_scrollBy(dx, dy, ensure_scroll_steps)
dx = Math.round(dx)
dy = Math.round(dy)
if BD.mirroredUILayout() then
dx = -dx
end
self._scroll_offset_x = self._scroll_offset_x + Math.round(dx)
self._scroll_offset_y = self._scroll_offset_y + Math.round(dy)
if self._scroll_offset_x < 0 then
local allow_overflow_x, allow_overflow_y = false, false
-- We allow controlled scrolling with swipes and PgDown/PgUp where the scroll
-- will align on a grid provided by the containee, so we can get better
-- alignment of the content and avoid truncated items.
if ensure_scroll_steps and self.step_scroll_grid then
-- We want to ensure that after the scroll, we won't have a truncated row at top,
-- and that any truncated row content at the point we're crossing will be fully
-- visible after the scroll.
-- When reaching top or bottom, we also allow overflow and display blank content,
-- for easier continuous browsing so we don't have to guess where we were if we
-- scrolled by less than a screen
local orig_x, orig_y = self._scroll_offset_x, self._scroll_offset_y
local new_x = orig_x + dx
local new_y = orig_y + dy
if orig_y <= 0 and dy <= 0 then
-- Already overflowing, and scrolling again in the same direction: reset the
-- overflow so we can get back in the sane state of anchored at top/bottom.
new_y = 0
elseif orig_y >= self._max_scroll_offset_y and dy >=0 then
-- Already overflowing, as above.
new_y = self._max_scroll_offset_y
else
allow_overflow_y = true -- this might be an option ?
local top_row, top_row_fully_visible, top_row_content_visible = -- luacheck: no unused
self:_getStepScrollRowAtY(orig_y, true)
local bottom_row, bottom_row_fully_visible, bottom_row_content_visible = -- luacheck: no unused
self:_getStepScrollRowAtY(orig_y + self._crop_h - 1, false)
local new_view_bottom_y = new_y + self._crop_h - 1
local new_top_row, new_top_row_fully_visible, new_top_row_content_visible = -- luacheck: no unused
self:_getStepScrollRowAtY(new_y, true)
if dy >= 0 then -- Scrolling down
if bottom_row and not bottom_row_content_visible and new_y > bottom_row.top then
-- If we'd go past the not fully visible original bottom button, have it fully at top
new_y = bottom_row.top
else
-- Ensure the new top row is anchored as its top
if new_top_row then
new_y = new_top_row.top
end
end
else -- Scrolling up
if top_row and not top_row_content_visible
and new_view_bottom_y < (top_row.content_bottom or top_row.bottom) then
-- If we'd go past the not fully visible original top button, be sure we'll
-- have its content fully at bottom
new_y = (top_row.content_bottom or top_row.bottom) - self._crop_h + 1
new_top_row, new_top_row_fully_visible, new_top_row_content_visible = -- luacheck: no unused
self:_getStepScrollRowAtY(new_y, true)
end
if not new_top_row and new_y < 0 then
-- Overflow. If the overflow is less than a ghost row before the first row,
-- do as what the next 'if's would do if it were there: anchor on the first row.
-- This may happen when back up to the first page: we don't want that small overflow.
-- (Not super sure this may not cause other issues like having the previous top
-- row duplicated at the new bottom.)
local first_row = self:_getStepScrollRowAtY(0)
if - new_y < first_row.bottom then
new_top_row, new_top_row_fully_visible, new_top_row_content_visible = -- luacheck: no unused
self:_getStepScrollRowAtY(0, true)
end
end
-- If the new top row is not fully visible, use the next row
if new_top_row and not new_top_row_fully_visible then
new_top_row, new_top_row_fully_visible, new_top_row_content_visible = -- luacheck: no unused
self:_getStepScrollRowAtY(new_top_row.bottom + 1, true)
end
-- Ensure the new top row is anchored as its top
if new_top_row then
new_y = new_top_row.top
end
end
end
self._scroll_offset_y = new_y
-- Step scrolling on the x-asis not yet implemented.
-- We should find in the top row table:
-- columns = { array of similar info about each button in that row's HorizontalGroup }
-- Its absence would mean free scrolling on the x-axis.
-- For now, allow free scrolling on the x-axis.
self._scroll_offset_x = new_x
else
-- Free scrolling
self._scroll_offset_x = self._scroll_offset_x + dx
self._scroll_offset_y = self._scroll_offset_y + dy
end
if self._scroll_offset_x < 0 and not allow_overflow_x then
self._scroll_offset_x = 0
end
if self._scroll_offset_y < 0 then
if self._scroll_offset_y < 0 and not allow_overflow_y then
self._scroll_offset_y = 0
end
if self._scroll_offset_x > self._max_scroll_offset_x then
if self._scroll_offset_x > self._max_scroll_offset_x and not allow_overflow_x then
self._scroll_offset_x = self._max_scroll_offset_x
end
if self._scroll_offset_y > self._max_scroll_offset_y then
if self._scroll_offset_y > self._max_scroll_offset_y and not allow_overflow_y then
self._scroll_offset_y = self._max_scroll_offset_y
end
self:_hideTruncatedGridItemsIfRequested()
self:_updateScrollBars()
UIManager:setDirty(self.show_parent, function()
return "ui", self.dimen
@ -248,6 +405,7 @@ function ScrollableContainer:reset()
self._bb = nil
end
self._is_scrollable = nil
self._crop_h_limited = nil
self._scroll_offset_x = 0
self._scroll_offset_y = 0
end
@ -294,7 +452,7 @@ function ScrollableContainer:paintTo(bb, x, y)
dx = self._scroll_offset_x
end
self[1]:paintTo(self._bb, x - dx, y - self._scroll_offset_y)
bb:blitFrom(self._bb, x + self._crop_dx, y, x + self._crop_dx, y, self._crop_w, self._crop_h)
bb:blitFrom(self._bb, x + self._crop_dx, y, x + self._crop_dx, y, self._crop_w, self._crop_h_limited or self._crop_h)
-- Draw our scrollbars over
if self._h_scroll_bar then
@ -351,16 +509,29 @@ function ScrollableContainer:onScrollableSwipe(_, ges)
end
self._scrolling = false -- could have been set by "pan" event received before "swipe"
local direction = ges.direction
local distance = ges.distance
local sq_distance = math.floor(math.sqrt(distance*distance/2))
if direction == "north" then self:_scrollBy(0, distance)
elseif direction == "south" then self:_scrollBy(0, -distance)
elseif direction == "east" then self:_scrollBy(-distance, 0)
elseif direction == "west" then self:_scrollBy(distance, 0)
elseif direction == "northeast" then self:_scrollBy(-sq_distance, sq_distance)
elseif direction == "northwest" then self:_scrollBy(sq_distance, sq_distance)
elseif direction == "southeast" then self:_scrollBy(-sq_distance, -sq_distance)
elseif direction == "southwest" then self:_scrollBy(sq_distance, -sq_distance)
if self.swipe_full_view then
-- Swipe by a full visible area, no matter the swipe distance
if direction == "north" then self:_scrollBy(0, self._crop_h, true)
elseif direction == "south" then self:_scrollBy(0, -self._crop_h, true)
elseif direction == "east" then self:_scrollBy(-self._crop_w, 0, true)
elseif direction == "west" then self:_scrollBy(self._crop_w, 0, true)
elseif direction == "northeast" then self:_scrollBy(-self._crop_w, self._crop_h, true)
elseif direction == "northwest" then self:_scrollBy(self._crop_w, self._crop_h, true)
elseif direction == "southeast" then self:_scrollBy(-self._crop_w, -self._crop_h, true)
elseif direction == "southwest" then self:_scrollBy(self._crop_w, -self._crop_h, true)
end
else
local distance = ges.distance
local sq_distance = math.floor(math.sqrt(distance*distance/2))
if direction == "north" then self:_scrollBy(0, distance, true)
elseif direction == "south" then self:_scrollBy(0, -distance, true)
elseif direction == "east" then self:_scrollBy(-distance, 0, true)
elseif direction == "west" then self:_scrollBy(distance, 0, true)
elseif direction == "northeast" then self:_scrollBy(-sq_distance, sq_distance, true)
elseif direction == "northwest" then self:_scrollBy(sq_distance, sq_distance, true)
elseif direction == "southeast" then self:_scrollBy(-sq_distance, -sq_distance, true)
elseif direction == "southwest" then self:_scrollBy(sq_distance, -sq_distance, true)
end
end
return true
end
@ -461,4 +632,20 @@ function ScrollableContainer:onScrollablePanRelease(_, ges)
return false
end
function ScrollableContainer:onScrollPageUp()
if not self._is_scrollable then
return false
end
self:_scrollBy(0, -self._crop_h, true)
return true
end
function ScrollableContainer:onScrollPageDown()
if not self._is_scrollable then
return false
end
self:_scrollBy(0, self._crop_h, true)
return true
end
return ScrollableContainer

Loading…
Cancel
Save