You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

521 lines
23 KiB

--[[
AutoScrollFrame is an intrinsic scrollframe widget that has a few benefits over HybridScrollFrame:
- No assembly required. The widget is self-contained and requires very little setup.
- Built to be resizable:
- List buttons are created on demand as the scrollframe expands.
- List buttons automatically adjust width based on the scrollframe's width.
- Scroll up/down buttons reliably update as the list size changes.
- Page up/down distance adjusts to the scrollframe size changes.
- More easily implement scrollframes with buttons of varied height.
- No globals except the mixin created. The entire widget is made of anonymous elements.
- No iteration over buttons. Instead uses a callback function to fill buttons.
- Heavily customized scrollbar that includes to-top and to-bottom scrollbar buttons.
- A capture button covers the empty part of short lists to support drag-and-drop.
To create:
<RematchAutoScrollFrame parentKey="PetList"/>
or
panel.PetList = CreateFrame("RematchAutoScrollFrame",nil,panel)
Required attributes (required for data to display):
.list (table) ordered list of data to display in the scrollframe (can be a list of tables)
.template (string) XML template name to use for list buttons
.callback (function(button,info)) function that's called to fill a button with info (info is a list entry)
Optional attributes:
.preUpdateFunc (function(self)) function to run before scrollFrame:Update() happens
.postUpdateFunc (function(self)) function to run after scrollFrame:Update() happens
.dynamicButtonHeight (function(self,index)) returns height of button at index
.templateType (string) the type of widget to use other than "Button" (eg. "RematchCompositeButton")
API expected to be used:
autoScrollFrame:Update() -- call to refresh the contents (no need to do this on size changes)
autoScrollFrame:ScrollToIndex(index) -- jump to an index (1-size or -1 for the end) in the list
autoScrollFrame:IsIndexVisible(index) -- returns true if indexed button is fully within the visible scrollframe
autoScrollFrame:ChangeTemplate(template) -- change template that the scrollframe uses
autoScrollFrame:BlingIndex(index) -- flashes the button at the given index
autoScrollFrame:GetButtonWidth() -- returns the current width of the scrollframe's buttons
autoScrollFrame:IsOverEmptyArea() -- returns true if the mouse is over the empty area of an incomplete list (the capture button)
For scrollframes where buttons can be variable height:
- Define .dynamicButtonHeight as a function that will return the height of the button at the given
index. No need to set button height in the callback; it's handled automatically during updates
- If all buttons are fixed height but the fixed height can change, don't define .dynamicButtonHeight
and instead define .buttonHeight and do an Update()
For callback, prior to the callback being called:
- The button has an .index assigned equal to the index in the list
- The button has been sized (width and height)
]]
local _,L = ...
local rematch = Rematch
RematchAutoScrollFrameMixin = {} -- functions to be used for each instance
local mixin = RematchAutoScrollFrameMixin
-- local functions
local createNewButton, adjustRange, setOffset, updateScrollButtons
local scrollByLine, scrollByPage, scrollByMousewheel
local getButtonHeight, updateDataHeight, getElementFromOffset, getOffsetFromIndex
--[[ OnLoad, OnShow and OnSizeChanged are intrinsicOrder="precall" ]]
-- precall OnLoad will setup the scrollframe
function mixin:OnLoad()
local scrollBar = self.ScrollFrame.ScrollBar
self.offset = 0 -- scrollbar offset in px
self.range = 0 -- max offset of scrollbar in pixels (dataHeight-scrollHeight); 0 when scrollbar disabled
self.element = 0 -- the index of the button that comes before the first visible button (0 means button1 is first visible)
self.elementOffset = 0 -- the offset just after the element (so top button lines up precisely)
self.overflow = 0 -- the percent (0.0-0.999) down the top button that the scrollframe is scrolled
self.dataHeight = 0 -- pixel height of all data as if it were one long frame
self.scrollHeight = nil -- pixel height of the visible scroll area
self.scrollWidth = nil -- pixel width of the visible scroll area
self.buttonHeight = nil -- pixel height of the button template for the first button
self.hasButtons = nil -- true when the scrollframe has buttons defined (everything initialized)
-- anchor PageUp/PageDown capture buttons between the thumb and respective up/down buttons
scrollBar.PageUpButton:SetPoint("TOP",scrollBar.UpButton,"BOTTOM")
scrollBar.PageUpButton:SetPoint("BOTTOM",scrollBar.ScrollThumb,"TOP",0,-2)
scrollBar.PageDownButton:SetPoint("TOP",scrollBar.ScrollThumb,"BOTTOM",0,3)
scrollBar.PageDownButton:SetPoint("BOTTOM",scrollBar.DownButton,"TOP",0,1)
-- set script handlers for controls that change the scrollframe/scrollbar offset
-- note closures! self is the parent frame and not the control!
-- sliding thumb
scrollBar:SetScript("OnValueChanged",function(_,value,userInput) setOffset(self,value) end)
-- mousewheel over scrollframe
self.ScrollFrame:SetScript("OnMouseWheel",function(_,delta) scrollByMousewheel(self,delta) end)
-- buttons that scroll up
scrollBar.TopButton:SetScript("OnClick",function() setOffset(self,0) end)
scrollBar.UpButton:SetScript("OnClick",function() scrollByLine(self,1) end)
scrollBar.PageUpButton:SetScript("OnClick",function() scrollByPage(self,1) end)
-- buttons that scroll down
scrollBar.BottomButton:SetScript("OnClick",function() setOffset(self,-1) end)
scrollBar.DownButton:SetScript("OnClick",function() scrollByLine(self,-1) end)
scrollBar.PageDownButton:SetScript("OnClick",function() scrollByPage(self,-1) end)
updateScrollButtons(self) -- disable scrollbar buttons initially (range is 0)
scrollBar.ScrollThumb:Hide() -- and thumb is hidden by default
end
-- precall OnShow will update the list if it's needed
function mixin:OnShow()
if self.updateNeeded then
self:Update()
end
end
-- precall when the parent frame changes size, adjust scrollchild size and do an update
function mixin:OnSizeChanged(w,h)
w = floor(w+0.5) -- round width to nearest integer
h = floor(h+0.5) -- height too
self.scrollWidth = w-9
self.scrollHeight = h-10
self.ScrollFrame:GetScrollChild():SetSize(self.scrollWidth,self.scrollHeight)
-- if scrollframe has buttons, run the update
if self.hasButtons then
self:Update()
end
end
--[[ autoScrollFrame:Update() and autoScrollFrame:ScrollToIndex(index) are intended for the scrollframe owner ]]
-- this is called when the list needs updated for any reason (content changes, scrollbar range/position changes, etc)
-- the owner of the scrollframe should call this (myScrollFrame:Update()) when it wants to do a manual update too
function mixin:Update()
if not self:IsVisible() then
-- don't do an update if scrollframe isn't on screen
self.updateNeeded = true -- but flag for an update when it's shown
return
end
-- if scrollframe doesn't have buttons nor data defined, hide scrollbar and leave
if not self.template or not self.list or not self.callback then
return
end
if self.updating then
-- if we're in an update already (like first render or setOffset near max range)
return -- then leave with no need to run again
end
self.updating = true
-- if no buttons defined yet, create just one to kick start table
if not self.ScrollFrame.Buttons then -- if no buttons have yet been made
self.ScrollFrame.Buttons = {createNewButton(self)}
self.ScrollFrame.Buttons[1]:SetPoint("TOPLEFT")
self.buttonHeight = floor(self.ScrollFrame.Buttons[1]:GetHeight()+0.5)
self.hasButtons = true
end
local buttons = self.ScrollFrame.Buttons
-- if an update happening before sizing force a resize to define a scrollHeight
if not self.scrollHeight then -- ** this may retrigger an Update; double check
self:OnSizeChanged(floor(self:GetWidth()+0.5),floor(self:GetHeight()+0.5))
end
-- recalculate height of all data
updateDataHeight(self)
-- update range if dataHeight or scrollHeight changed
adjustRange(self)
-- if a pre-update wrap exists, run it
if self.preUpdateFunc then
self.preUpdateFunc(self)
end
local list = self.list -- the table of data we're displaying
local listSize = #self.list -- the size of the list
local listIndex = self.element + 1 -- the index of the current button to be updated
-- the remaining height of the scrollFrame includes the portion of the overflow button
-- that's extended above the top button; as each button is added its height is removed
-- from this remaining total; when it gets to 0 we're done updating list buttons
local remaining = self.scrollHeight+self.overflow -- remaing height of scrollframe to fill
local callback = self.callback -- user-defined function to update a single button's contents
local buttonWidth = self.scrollWidth-25 -- width adjusted based on scrollWidth
local lastButton -- only relevant for lists that don't fill scrollFrame
-- update a button as long as there's data to show and there's area remaining to fill
local buttonIndex = 1
while listIndex<=listSize and remaining do
local buttonHeight = getButtonHeight(self,listIndex)
if buttonHeight and buttonHeight>0 then -- confirm there's a button of an actual height to display
-- create button if it's needed
if not buttons[buttonIndex] then
buttons[buttonIndex] = createNewButton(self)
buttons[buttonIndex]:SetPoint("TOPLEFT",buttons[buttonIndex-1],"BOTTOMLEFT")
end
if remaining<0 then -- if we've consumed all of the scrollHeight remaining, stop after this button
remaining = nil
else -- reduce the remaining scrollHeight to update by the buttonHeight
remaining = remaining - buttonHeight
end
-- set index, size and run callback
local button = buttons[buttonIndex]
button.index = listIndex
button:SetSize(buttonWidth,buttonHeight)
callback(button,self.list[listIndex])
button:Show()
lastButton = button
-- and increment list and button index to do next button
listIndex = listIndex + 1
buttonIndex = buttonIndex + 1
else -- if no buttonHeight then finish (should never run this; just for insurance)
remaining = nil
end
end
-- hide any remaining buttons that didn't get updated
for i=buttonIndex,#buttons do
buttons[i]:Hide()
end
-- if list didn't fill whole scrollframe, display capture button
local captureButton = self.ScrollFrame.CaptureButton
if self.range==0 then -- list didn't fill scrollframe (scrollbar disabled)
captureButton:Show()
captureButton.lastButton = lastButton -- can potentially be nil if 0 items in list!
captureButton:SetPoint("TOP",lastButton or self.ScrollFrame,lastButton and "BOTTOM" or "TOP")
else
captureButton:Hide()
lastButton = nil
end
-- if a post-update wrap exists, run it
if self.postUpdateFunc then
self.postUpdateFunc(self)
end
-- all done!
self.updating = nil
end
-- scrolls the list to the given index (1->self.listSize), making it centered as best it can.
-- a negative index will scroll to the end
function mixin:ScrollToIndex(index)
local offset
if index==-1 then -- special case to scroll to end for -1 index
offset = -1
elseif self.range==0 then -- if scrollbar disabled, scroll to top
offset = 0
else -- otherwise find an offset that puts the index centered as best it can in the visible list
offset = getOffsetFromIndex(self,index)
end
setOffset(self,offset)
end
-- returns the indexed button if it's fully visible within the scrollframe, nil otherwise
function mixin:IsIndexVisible(index)
local buttons = self.ScrollFrame.Buttons
for _,button in ipairs(buttons) do
if button.index==index and button:IsVisible() and button:GetTop()<(self.ScrollFrame:GetTop()+8) and button:GetBottom()>(self.ScrollFrame:GetBottom()-8) then
return button
end
end
end
-- changes the template for the scrollframe; this should not be called frequently since each time
-- it will create new buttons and the old ones will never be garbage collected. For Rematch it's used
-- when CompactListMode changes, which is a rare event. For frequent changes, keep one template and
-- change the .buttonHeight and repurpose the template.
function mixin:ChangeTemplate(template)
if type(template)=="string" and self.template~=template then -- only bother changing template if it's changing
self.template = template
local buttons = self.ScrollFrame.Buttons
if buttons then -- only if template was previously applied in an Update
for _,button in ipairs(buttons) do
button:Hide() -- hide all old buttons
end
self.ScrollFrame.Buttons = nil -- remove buttons table so new one will be made
self:Update() -- and force an update
end
end
end
-- this will flash an animated fade in-out texture over the indexed button, to draw attention to it
function mixin:BlingIndex(index)
local button = self:IsIndexVisible(index)
local bling = self.ScrollFrame.Bling
if button then
bling:ClearAllPoints()
bling:SetParent(button)
-- anchor bling to the "Back" texture if it exists and it's visible
local anchorTo = button.Back
if not anchorTo or not anchorTo:IsVisible() then
anchorTo = button -- otherwise anchor bling to whole button
end
bling:SetPoint("TOPLEFT",anchorTo,"TOPLEFT",1,-1)
bling:SetPoint("BOTTOMRIGHT",anchorTo,"BOTTOMRIGHT",-1,1)
bling:SetFrameLevel(button:GetFrameLevel()+5)
bling:Show()
end
end
-- returns the current width of buttons
function mixin:GetButtonWidth()
return self.scrollWidth-25
end
-- returns true if the mouse is over the capture button, or the blank area when the scrollframe list isn't full
function mixin:IsOverEmptyArea()
return MouseIsOver(self.ScrollFrame.CaptureButton)
end
--[[ Local functions shared by all of the above methods]]
-- creates and returns a new button based on the stored template
function createNewButton(self)
-- to create a list of CompositeButtons, define templateType as "RematchCompositeButton"
local templateType = self.templateType or "Button"
return CreateFrame(templateType,nil,self.ScrollFrame:GetScrollChild(),self.template)
end
-- adjusts the scrollbar's range based on the height of data and scrollframe
-- this should be called after updateDataHeight
function adjustRange(self)
local listSize = #self.list
local range = max(0,self.dataHeight-self.scrollHeight)
-- only need to make adjustments if range has changed
if range~=self.range then
self.ScrollFrame.ScrollBar:SetMinMaxValues(0,range)
local offset = max(0,min(range,self.offset))
-- if we were at the end of the list prior to adjustment, change offset to new end of range
if self.offset==self.range and offset~=0 then
offset = range
end
self.range = range
-- if offset changed (or we're at start) then set new offset
if self.offset~=offset or offset==0 then
self.offset = offset
setOffset(self,offset)
elseif offset==range then -- otherwise if we're at the end of the list
updateScrollButtons(self) -- just update the buttons (so down is disabled on a resize)
end
end
end
-- based off HybridScrollFrame_SetOffset in HybridScrollFrame.lua
-- self is the handle, offset is the intended absolute offset in pixels
function setOffset(self,offset)
-- special case, if offset is -1 then scroll to end (self.range)
if offset==-1 then
offset = self.range
end
-- constraint offset between 0 and max range and round it to a whole number
self.offset = floor(max(0,min(self.range,offset))+0.5)
-- recalculate the element and related info from the offset
local element, elementOffset, overflow = getElementFromOffset(self,self.offset)
local changed = self.element ~= element -- before saving, note if we're changing elements
self.element = element
self.overflow = overflow
self.elementOffset = elementOffset
if changed then -- if element has changed then update buttons
self:Update()
end
self.ScrollFrame:SetVerticalScroll(self.overflow)
self.ScrollFrame.ScrollBar:SetValue(self.offset)
updateScrollButtons(self)
end
-- updates the state of the scroll/page up/down buttons and whether thumb displays
function updateScrollButtons(self)
local scrollBar,range,offset = self.ScrollFrame.ScrollBar,self.range,self.offset
scrollBar.ScrollThumb:SetShown(range>0)
-- update scroll/page up
local canScrollUp = range>0 and offset>0
scrollBar.TopButton:SetEnabled(canScrollUp)
scrollBar.UpButton:SetEnabled(canScrollUp)
scrollBar.PageUpButton:SetEnabled(canScrollUp)
local canScrollDown = range>0 and offset<range
scrollBar.BottomButton:SetEnabled(canScrollDown)
scrollBar.DownButton:SetEnabled(canScrollDown)
scrollBar.PageDownButton:SetEnabled(canScrollDown)
end
-- scrolls up or down one line, making the top button align with the top afterwards (overflow=0)
-- delta of 1 = scroll up, delta of -1 = scroll down
function scrollByLine(self,delta)
local newOffset
if delta==1 and self.overflow>0 then -- if we're scrolling up and top button is partially hidden
newOffset = self.elementOffset -- then scroll up to top of the partially hidden button
else
local buttonHeight
if delta==1 and self.element>0 then
buttonHeight = getButtonHeight(self,self.element)
else
buttonHeight = getButtonHeight(self,self.element+1)
end
newOffset = self.elementOffset - delta*buttonHeight -- otherwise scroll up/down next whole button
end
newOffset = max(0,min(self.range,newOffset)) -- keep it constrained to 0-range
--local newOffset = max(0,min(self.range,(floor(self.element)-delta)*buttonHeight))
if newOffset ~= self.offset then
setOffset(self,newOffset)
end
end
-- scrolls up or down by a whole scrollHeight page
function scrollByPage(self,delta)
local newOffset
if delta==1 then -- if scrolling up then scroll up a scrollHeight less one button height
newOffset = self.elementOffset - delta*(self.scrollHeight-getButtonHeight(self,self.element))
else -- if scrolling down then scroll whole scrollHeight
newOffset = self.elementOffset - delta*(self.scrollHeight)
end
local element, elementOffset, overflow = getElementFromOffset(self,newOffset)
newOffset = max(0,min(self.range,elementOffset))
if newOffset ~= self.offset then
setOffset(self,newOffset)
end
end
-- mousewheel scrolls by line or page depending on SlowMousewheelScroll setting
function scrollByMousewheel(self,delta)
if self.range>0 then
if RematchSettings.SlowMousewheelScroll then
scrollByLine(self,delta)
else
scrollByPage(self,delta)
end
end
end
-- returns the height of the button at the index
function getButtonHeight(self,index)
-- if a dynamic function defined, get it from the callback
if self.dynamicButtonHeight then
if index>0 and index<=#self.list then -- only return a height of index is valid
return self.dynamicButtonHeight(self,index) or 0
else -- for invalid index return 0
return 0
end
else -- for fixed height buttons return saved height
return self.buttonHeight
end
end
-- updates self.dataHeight to be the pixel height of all data in the list
function updateDataHeight(self)
local dataHeight = 0
-- if using dynamic heights, calculate total height
if self.dynamicButtonHeight then
for i=1,#self.list do -- add height of button at each index to total
dataHeight = dataHeight + getButtonHeight(self,i)
end
else -- otherwise it's simply size of list times fixed button height
dataHeight = #self.list * self.buttonHeight
end
self.dataHeight = dataHeight
end
-- this function takes an offset and returns the element, elementOffset and overflow from that offset
function getElementFromOffset(self,offset)
local element = 0 -- index of button before first visible on the list
local elementOffset = 0 -- offset at the end of that button (so top of first visible button)
local overflow = 0 -- pixels the top button is scrolled up (remainder)
if not self.dynamicButtonHeight then -- if this scrollframe's buttons are fixed height
local buttonHeight = self.buttonHeight
local roughElement = offset/buttonHeight -- approximate index of button prior to first on screen
element = floor(roughElement) -- element is the actual index
overflow = floor((roughElement-element)*buttonHeight+0.5) -- remainder goes into overflow
elementOffset = element*buttonHeight -- offset to end of element
else -- otherwise this scrollframe's buttons are dynamic height (bit more complicated!)
local prior = 0 -- pixels prior to present offset
local index = 1 -- starting with first list index
-- for variable-height buttons we increment the element by adding buttonHeights to
-- a total (prior) until we've reached the offset
while prior<offset do
local buttonHeight = getButtonHeight(self,index)
local post = prior + buttonHeight -- the "after" prior value, saving in a separate variable
if buttonHeight and buttonHeight>0 then
if post>offset then -- if we've passed the offset
overflow = buttonHeight-(post-offset) -- then store the remaining pixels into the overflow
else
element = element + 1 -- otherwise increment element
elementOffset = elementOffset+buttonHeight -- offset to end of element
end
prior = post -- update prior to the version added to the buttonHeight
else -- safety check: if buttonHeight won't advance prior, leave
prior = offset
end
index = index + 1
end
end
return element, elementOffset, overflow
end
-- this takes an index and converts it to an offset where the indexed item is centered
-- in the list and the top button/element is aligned (overflow is 0)
function getOffsetFromIndex(self,index)
local offset = 0
if not self.dynamicButtonHeight then -- for fixed heights this is easy
offset = (index+1)*self.buttonHeight -- index+1 to pad offset to slightly above center
else -- for dynamic heights need to total up heights
for i=1,index+1 do -- index+1 to pad offset to slightly above center here too
offset = offset+getButtonHeight(self,i)
end
end
offset = offset - self.scrollHeight/2 -- adjust offset by half of the scrollHeight to center it
-- to get the top button to line up, getting the elementOffset as the final offset
local _,elementOffset = getElementFromOffset(self,offset)
return elementOffset
end