Zoomable, infinitely scrollable single screen: ZoomScroller

This code took may way way too long to argue ChatGPT into writing for me, because for some reason it couldn’t make it work using anything but boundary definitions. So it works with boundary definitions, lol.

It takes an image and:

  • makes it full screen
  • allows zooming in (but only zooms out as large as the starting screen size)
  • allows zooming fingers to scroll the image infinitely in any direction, wrapping it around the screen so its borders align with their own opposite borders

Other notes:

  • requires the Touch Sensor project (available in WebRepo)
  • the frame attribute can also be used without an image, to provide an ability to define zoomable coordinates for any other kind of content


function setup()
    local spriteAsset = asset.builtin.Surfaces.Basic_Bricks_Color -- Example image, now used only in draw
    zoomScroller = ZoomScroller(spriteAsset)
    
    -- Setup sensor for pinch gestures
    screen = {x=0, y=0, w=WIDTH, h=HEIGHT}
    sensor = Sensor {parent=screen}
    sensor:onZoom(function(event) 
        zoomScroller:zoomCallback(event)
    end)
end

function touched(touch)
    -- Pass touch events to the sensor
    sensor:touched(touch)
end

function draw()
    background(40, 40, 50)
    -- drawTiledImage function now uses the frame to draw the image within it
    zoomScroller:drawTiledImageInBounds()
end

ZoomScroller = class()

function ZoomScroller:init(anImage, x, y, width, height)
    -- Constructor for the ZoomableFrame class
    self.frame = {x = x or WIDTH / 2, y = y or HEIGHT / 2, width = width or WIDTH, height = height or HEIGHT, lastMidpoint = nil, initialDistance = nil}
    self.image = anImage
end

function ZoomScroller:repositionBoundsIfOffscreen()
    -- Reposition frame if offscreen
    local bounds = self.frame
    -- Check if bounds are offscreen to the right
    if bounds.x > WIDTH then
        bounds.x = bounds.x - 2 * bounds.width
    end
    -- Check if bounds are offscreen to the left
    if bounds.x + bounds.width < 0 then
        bounds.x = bounds.x + 2 * bounds.width
    end
    -- Check if bounds are offscreen to the bottom
    if bounds.y > HEIGHT then
        bounds.y = bounds.y - 2 * bounds.height
    end
    -- Check if bounds are offscreen to the top
    if bounds.y + bounds.height < 0 then
        bounds.y = bounds.y + 2 * bounds.height
    end
end

function ZoomScroller:zoomCallback(event)
    self:repositionBoundsIfOffscreen()
    
    local touch1 = event.touches[1]
    local touch2 = event.touches[2]
    
    local initialDistance = math.sqrt((touch1.prevX - touch2.prevX)^2 + (touch1.prevY - touch2.prevY)^2)
    local currentDistance = math.sqrt((touch1.x - touch2.x)^2 + (touch1.y - touch2.y)^2)
    local distanceChange = currentDistance - initialDistance
    
    local currentMidpoint = vec2((touch1.x + touch2.x) / 2, (touch1.y + touch2.y) / 2)
    
    if touch1.state == BEGAN or touch2.state == BEGAN then
        self.frame.lastMidpoint = currentMidpoint
        self.frame.initialDistance = initialDistance
    else
        local midpointChange = currentMidpoint - self.frame.lastMidpoint
        
        if distanceChange ~= 0 and self.frame.initialDistance then
            local scaleChange = currentDistance / self.frame.initialDistance
            self.frame.initialDistance = currentDistance
            
            local newWidth = self.frame.width * scaleChange
            local newHeight = self.frame.height * scaleChange
            
            if newWidth < WIDTH or newHeight < HEIGHT then
                return
            end
            
            local offsetX = (self.frame.width - newWidth) * ((currentMidpoint.x - self.frame.x) / self.frame.width)
            local offsetY = (self.frame.height - newHeight) * ((currentMidpoint.y - self.frame.y) / self.frame.height)
            
            self.frame.x = self.frame.x + offsetX
            self.frame.y = self.frame.y + offsetY
            self.frame.width = newWidth
            self.frame.height = newHeight
        end
        
        if midpointChange.x ~= 0 or midpointChange.y ~= 0 then
            self.frame.x = self.frame.x + midpointChange.x
            self.frame.y = self.frame.y + midpointChange.y
        end
        
        self.frame.lastMidpoint = currentMidpoint
    end
end

function ZoomScroller:drawTiledImageInBounds(anImageOrNot)
    local anImage = anImageOrNot or self.image
    local bounds = self.frame
    local tilesX = math.ceil(WIDTH / bounds.width) + 1
    local tilesY = math.ceil(HEIGHT / bounds.height) + 1
    
    local startX = bounds.x % bounds.width
    if startX > 0 then startX = startX - bounds.width end
    
    local startY = bounds.y % bounds.height
    if startY > 0 then startY = startY - bounds.height end
    
    for i = -1, tilesX do
        for j = -1, tilesY do
            local x = startX + (i * bounds.width)
            local y = startY + (j * bounds.height)
            sprite(anImage, x, y, bounds.width, bounds.height)
        end
    end
end


Hah, I love this. At some point you will run out of floating point accuracy, though I wonder how long it will take

1 Like

You mean from scrolling left forever? I don’t think so—the drawTiledImageInBounds function draws everything in relationship to the first image, and it swaps the first image to the other side of the screen whenever it’s fully off screen. I think! That’s what it’s supposed to do at least.

1 Like

Ohh I see, you’re actually moving back to center seamlessly

@UberGoober - interesting, smooth. Was surprised to see it change the orientation of the image to match that of the iPad screen.