How can I make a pie timer? I need to be able to draw a circular arc with dynamically changing angles.

Well, at long last I’ve finally finished polishing my progressRing module (with documentation, a sample project, and demo video) and I’m ready to share it. Hopefully it’ll feel worth the wait. Sorry it took so long!

A detailed description of how it works, plus download links for the module itself and a sample project can be found at my website: http://www.jasonschroeder.com/2014/12/21/progress-ring-module-for-corona-sdk/

And here’s a video of how it looks in action (this is also the sample project you can download):

http://youtu.be/cRwvVfLQnzQ

And in case you’re not in the mood to visit my site, here’s the full content of progressRing.lua - just require it into your project and call progressRing.new() to create a progress ring (but you can customize it by passing a table with your parameters):

[lua]

– progressRing Module for Corona SDK

– Copyright © 2014 Jason Schroeder

http://www.jasonschroeder.com

http://www.twitter.com/schroederapps

–[[ Permission is hereby granted, free of charge, to any person obtaining a copy

of this software and associated documentation files (the “Software”), to deal

in the Software without restriction, including without limitation the rights

to use, copy, modify, merge, publish, distribute, sublicense, and/or sell

copies of the Software, and to permit persons to whom the Software is

furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in

all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR

IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,

FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE

AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER

LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,

OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN

THE SOFTWARE. ]]–


– HOW TO USE THIS MODULE


– Step One: put this lua file in your project’s root directory

– Step Two: require the module in your project as such:

   – local progressRing = require(“progressRing”)

– Step Three: create a progress ring object as such:

   – local ringObject = progressRing.new([params])

   – You can customize the look of your progress ring by including a single table as an argument when calling progressRing.new(). The table can include any of the following key/value pairs (but none are required):

      – radius: a number representing the radius of your ring in pixels. Defaults to 100.

      – ringColor: a table containing 4 numbers between 0 and 1, representing the RGBA values of your ring’s bar color. Defaults to {1, 1, 1} (white).

      – bgColor: a table containing 4 numbers between 0 and 1, representing the RBGA values of your ring’s background color. Defaults to {0, 0, 0} (black).

      – ringDepth: a number between 0 and 1 representing the depth of your ring. A ringDepth of 1 will result in a fully-round ring (“all donut, no hole”), while a ringDepth of 0 would result in an invisible ring (“all hole, no donut”). Defaults to .33.

      – strokeColor: a table containing 4 numbers between 0 and 1, representing the RBGA values of your ring’s stroke (border) color. Defaults to whatever your bgColor is.

      – strokeWidth: a number representing the width of your ring’s stroke (border) in pixels. Defaults to 0 (no stroke).

      – counterclockwise: a boolean (true/false) value indicating whether or not the ring should advance in a counter-clockwise manner. Defaults to false.

      – hideBG: a boolean (true/false) value indicating whether or not the background should be visible. Defaults to false.

      – time: a number representing the amount of time (in milliseconds) your ring will take to make a full rotation, from 0 to 360 degrees. Defaults to 10000 (10 seconds).

      – position: a number between 0 and 1 representing the starting position of your progress bar. 0 would result in no visible progress bar (i.e. 0 degrees). 1 would result in a full progress bar (i.e. 360 degrees). .5 would result in a halfway-full progress bar (i.e. 180 degrees). Defaults to 0.

      – bottomImage: a string representing the path to an image file (i.e. “images/bottom.png”) that will appear “underneath” or “behind” your progress ring. Automatically supports dynamic image scaling (@2x, @3x, etc.). Defaults to nil.

      – topImage: a string representing the path to an image file (i.e. “images/top.png”) that will appear “on top of” or “in front of” your progress ring. Automatically supports dynamic image scaling (@2x, @3x, etc.). Defaults to nil.

      

– Step Four: you can change the position of your progress ring using the following methods:

   – ringObject:goTo(position, [time]) is used to advance/retreat the position of the progress ring. Note:

      – position (required) is a number between 0 and 1 representing the position the bar should advance or retreat to.

      – time (optional) is a number representing the amount of time (in milliseconds) it will take for your ring to reach the position you defined. By default, this is determined by the time you set for a full rotation. (i.e. if you set a time of 10 seconds for a full 360-degree rotation, and your ring is advancing 180 degrees, it will take 5 seconds). Setting a time of 0 will result in an immediate repositioning of your progress ring.

   – ringObject:pause() will pause your progress ring while advancing or retreating.

   – ringObject:resume() will resume a paused progress ring.

   – ringObject:reset() will return your progress ring to the starting position you defined when creating the object.


– FINALIZE BUG FIX:

– Prior to Corona build # 2015.2544, there was a bug in Corona SDK that prevented

– finalize events from being called for children objects when group is removed. This

– code fixes that bug, in case you are using an older build for your app.

– This bug fix was written by the incomparable @SergeyLerg - thanks, Sergey!


local function fixFinalize()

   print(“Fixing finalize() bug…”)

   local function finalize(event)

      local g = event.target

      for i = 1, g.numChildren do

         if g[i]._tableListeners and g[i]._tableListeners.finalize then

            for j = 1, #g[i]._tableListeners.finalize do

               g[i]._tableListeners.finalize[j]:dispatchEvent{name = ‘finalize’, target = g[i]}

            end

         end

         if g[i]._functionListeners and g[i]._functionListeners.finalize then

            for j = 1, #g[i]._functionListeners.finalize do

               g[i]._functionListeners.finalize[j]({name = ‘finalize’, target = g[i]})

            end

         end

         if g[i].numChildren then

            finalize{target = g[i]}

         end

      end

   end

   local newGroup = display.newGroup

   function display.newGroup()

      local g = newGroup()

      g:addEventListener(‘finalize’, finalize)

      return g

   end

end

local finalizeFixed = false

local testGroup = display.newGroup()

testGroup.isVisible = false

local testObject = display.newRect(testGroup, 0, 0, 1, 1)

function testObject.finalize(self, event)

   finalizeFixed = true

   print(“no longer any need to fix finalize() - thanks Corona Labs!”)

end

testObject:addEventListener(“finalize”)

display.remove(testGroup)

timer.performWithDelay(1, function()

   if not finalizeFixed then fixFinalize() end

   finalizeFixed, testGroup, testObject = nil, nil, nil

end)


– CREATE TABLE TO HOLD MODULE


local progressRing = {}


– SCREEN POSITIONING VARIABLES


local centerX = display.contentCenterX

local centerY = display.contentCenterY

local screenTop = display.screenOriginY

local screenLeft = display.screenOriginX

local screenBottom = display.screenOriginY+display.actualContentHeight

local screenRight = display.screenOriginX+display.actualContentWidth

local screenWidth = screenRight - screenLeft

local screenHeight = screenBottom - screenTop


– CREATE NEW PROGRESS RING


function progressRing.new(params)

   – available params are: radius, ringColor, bgColor, strokeColor, ringDepth, strokeWidth, hideBG, time, position, topImage, bottomImage

   if params == nil then params = {} end

   

   --------------------------------------------------------------------------------

   – LOCALIZE PARAMS & SET DEFAULTS

   --------------------------------------------------------------------------------

   local radius = params.radius or 100

   local counterclockwise = params.counterclockwise

   local ringColor = params.ringColor or {1, 1, 1}

   local bgColor = params.bgColor or {0, 0, 0}

   local strokeColor = params.strokeColor or bgColor

   local ringDepth = params.ringDepth or .33

   local strokeWidth = params.strokeWidth or 0

   local hideBG = params.hideBG

   local time = params.time or 10000

   local startPosition = params.position or 0

   local topImage = params.topImage

   local bottomImage = params.bottomImage

   

   if ringDepth > 1 then ringDepth = 1 elseif ringDepth <0 then ringDepth = 0 end

   

   --------------------------------------------------------------------------------

   – CREATE PROGRESS RING VISUALS

   --------------------------------------------------------------------------------

   local group = display.newGroup()

   group.position = startPosition

   local objectName = tostring(group)

   if counterclockwise == true then group.xScale = -1 end

   local sliceGroup = display.newGroup()

   sliceGroup.group = sliceGroup

   function sliceGroup.invalidate() end

   if ringColor[4] ~= nil and ringColor[4] < 1 and ringColor[4] > 0 then

      sliceGroup = nil

      sliceGroup = display.newSnapshot(radius*2, radius*2)

      sliceGroup.alpha = ringColor[4]

   end

   local sliceContainer = display.newContainer(group, radius*2, radius*2)

   sliceContainer.anchorChildren = false

   sliceContainer.anchorX = 0

   group.isVisible = false

   

   local slices = {}

   

   local bg = display.newCircle(group, 0, 0, radius)

   bg:setFillColor(unpack(bgColor))

   group:insert(sliceGroup)

   if hideBG then bg.isVisible = false end

   local stroke1 = display.newCircle(group, 0, 0, radius + strokeWidth*.5)

   stroke1:setFillColor(0, 0, 0, 0)

   stroke1:setStrokeColor(unpack(strokeColor))

   stroke1.strokeWidth = strokeWidth

   local stroke2 = display.newCircle(group, 0, 0, radius - radius*ringDepth - strokeWidth/2)

   stroke2:setFillColor(0, 0, 0, 0)

   stroke2:setStrokeColor(unpack(strokeColor))

   stroke2.strokeWidth = strokeWidth

   

   if bottomImage ~= nil then

      local getDims = display.newImage(bottomImage)

      getDims.isVisible = false

      local w, h = getDims.width, getDims.height

      group.bottomImage = display.newImageRect(bottomImage, w, h)

      group.bottomImage.x, group.bottomImage.y = 0, 0

      group:insert(1, group.bottomImage)

      display.remove(getDims)

      getDims = nil

   end

   

   if topImage ~= nil then

      local getDims = display.newImage(topImage)

      getDims.isVisible = false

      local w, h = getDims.width, getDims.height

      group.topImage = display.newImageRect(group, topImage, w, h)

      group.topImage.x, group.topImage.y = 0, 0

      display.remove(getDims)

      getDims = nil

   end

   

   --------------------------------------------------------------------------------

   – ADD PROGRESS RING “SLICES”

   --------------------------------------------------------------------------------

   local sliceHeight = radius * 1.5

   for i = 0, 350, 10 do

      local slice = display.newPolygon(0, 0, {0, 0, 0, -sliceHeight, sliceHeight*.182, -sliceHeight})

      local ringColor = {ringColor[1], ringColor[2], ringColor[3]}

      slice:setFillColor(unpack(ringColor))

      slice.anchorX, slice.anchorY = 0, 1

      slice.target = i

      if i >=180 then

         sliceGroup.group:insert(slice)

      else

         sliceContainer:insert(slice)

      end

      slice.rotation = -10

      slices[#slices+1] = slice

   end

   

   --------------------------------------------------------------------------------

   – CREATE CIRCULAR MASK FOR SLICES

   --------------------------------------------------------------------------------

   local stageColor = display.getDefault(“background”)

   local coverUp = display.newRect(centerX, centerY, screenWidth, screenHeight)

   coverUp:setFillColor(stageColor)

   display.getCurrentStage():insert(1, coverUp)

   

   local squareSize = screenWidth - 16

   if screenWidth > screenHeight then squareSize = screenHeight - 16 end

   squareSize = math.floor(squareSize*.25)*4 + 16

   local maskRadius = squareSize * .5 - 16

   local maskScaleX = radius / (maskRadius / display.contentScaleX)

   local maskScaleY = radius / (maskRadius / display.contentScaleY)

   

   local maskGroup = display.newGroup()

   maskGroup.x, maskGroup.y = centerX, centerY

   display.getCurrentStage():insert(1, maskGroup)

   local square = display.newRect(maskGroup, 0, 0, squareSize, squareSize)

   square:setFillColor(0)

   local circle = display.newCircle(maskGroup, 0, 0, maskRadius)

   circle:setFillColor(1)

   local circle2 = display.newCircle(maskGroup, 0, 0, maskRadius*(1-ringDepth))

   circle2:setFillColor(0)

   timer.performWithDelay(10, function()

      local maskImage = display.capture(maskGroup, { saveToPhotoLibrary=false, isFullResolution=false } )

      display.getCurrentStage():insert(1, maskImage)

   

      timer.performWithDelay(1, function()

         display.save( maskImage, {filename=objectName…".jpg", baseDir=system.TemporaryDirectory, isFullResolution=true} )

         local mask = graphics.newMask( objectName…".jpg", system.TemporaryDirectory )

         

         timer.performWithDelay(1, function()

            bg:setMask(mask)

            bg.maskScaleX, bg.maskScaleY = maskScaleX, maskScaleY

            sliceGroup.group:setMask(mask)

            sliceGroup.maskScaleX, sliceGroup.maskScaleY = maskScaleX, maskScaleY

            display.remove(maskGroup)

            display.remove(maskImage)

            display.remove(coverUp)

            sliceGroup.group:insert(sliceContainer)

            group.isCreated = true

            group:goTo(startPosition, 0)

            group.isVisible = true

         end)

      end)

   end)

   

   --------------------------------------------------------------------------------

   – FUNCTION TO RUN WHEN ROTATION IS COMPLETED

   --------------------------------------------------------------------------------

   local function onComplete()

      group:dispatchEvent({name = “completed”})

   end

   

   --------------------------------------------------------------------------------

   – SET RING POSITION (i.e. start rotation)

   --------------------------------------------------------------------------------

   function group.goTo(self, position, customTime)

      if group.isCreated then

         transition.cancel(objectName)

         customTime = customTime or math.abs(group.position - position)*time

         if position > 1 then position = 1 elseif position < 0 then position = 0 end

         transition.to(slices[#slices], {rotation = position*360 - 10, time = customTime, tag = objectName, onComplete = onComplete})

      else

         timer.performWithDelay(50, function()

            group:goTo(position, customTime)

         end)

      end

   end

   

   --------------------------------------------------------------------------------

   – RESET RING ROTATION

   --------------------------------------------------------------------------------

   function group.reset(self)

      transition.cancel(objectName)

      group:dispatchEvent({name = “reset”})

      group:goTo(startPosition, 0)

   end

   

   --------------------------------------------------------------------------------

   – PAUSE RING ROTATION

   --------------------------------------------------------------------------------

   function group.pause(self)

      transition.pause(objectName)

      group:dispatchEvent({name = “paused”})

   end

   

   --------------------------------------------------------------------------------

   – RESUME PAUSED ROTATION

   --------------------------------------------------------------------------------

   function group.resume(self)

      transition.resume(objectName)

      group:dispatchEvent({name = “resumed”})

   end

   

   --------------------------------------------------------------------------------

   – RUNTIME LISTENER TO SET SLICE ROTATIONS & MAKE VISIBLE/INVISIBLE

   --------------------------------------------------------------------------------

   function group.runtimeListener(event)

      local targetSlice = slices[#slices]

      targetSlice.isVisible = targetSlice.rotation >=0

      group.position = (targetSlice.rotation + 10)/360

      for i = 1,#slices-1 do

         local slice = slices[i]

         slice.isVisible = slice.rotation > -10

         if targetSlice.rotation <= slice.target then

            slice.rotation = targetSlice.rotation

         else

            slice.rotation = slice.target

         end

         if i >= #slices*.5 then

            slice.isVisible = slice.rotation >=0

         end

      end

      sliceGroup:invalidate()

   end

   

   Runtime:addEventListener(“enterFrame”, group.runtimeListener)

   

   --------------------------------------------------------------------------------

   – FINALIZE CLEANUP WHEN PROGRESS RING IS REMOVED

   --------------------------------------------------------------------------------

   function group.finalize(self, event)

      transition.cancel(objectName)

      Runtime:removeEventListener(“enterFrame”, group.runtimeListener)

   end

   group:addEventListener(“finalize”)

   

   --------------------------------------------------------------------------------

   – RETURN PROGRESS RING OBJECT

   --------------------------------------------------------------------------------

   return group

end


– RETURN MODULE


return progressRing

[/lua]

@schroederapps wow that looks great, thank you for sharing!

Really nice, good work!

I notice that the chart ‘bleeds out’ at the bottom on the iPad (iOS7).

Do you know why ? See the screenshot.

This is likely related to the dynamic masking method I employ, which still has a few kinks that present themselves in certain situations. Try reducing the radius a bit and see if you still get the same issue at all radius values. This didn’t happen for me on my iPad Air back when I created the module, but perhaps you’re on an iPad 2? I don’t have one of those, so I’m unable to test directly.

I hope to nail down a solid fix that will “always work” for generating dynamic masks. Once I do, I’ll be sure to update the module. Sorry it wasn’t as smooth as it should be for you!

Hi,

Ya. I am on iPad mini with the retina display (either iPad2 or 3).

I tried to adjust the values, but they are still the same. Pls see the screenshots.

Thanks

for what it’s worth, and with no intent to diminish the cleverness of Jason’s existing solution, you can get around all this mucking about with masks by just creating a bunch of “wedges” (ok, technically isosceles trapezoids) between the inner and outer radii along the chords at whatever degree of segmenting you want to chop up the circle.  (or even just rectangles - slightly different look, but one you’ve seen before)

if you segment up the circle fine enough, the discreteness is indistinguishable from continuous.  see attached top left-most instance where finely segmented, compare with 3rd on top row where more coarsely segmented.  (others are just additional instances of same class with varying segmentation, radii, thickness, etc)

if as you’re creating each “tick mark” you give it a ratio property (fe, i’m number 7 of 100, or 0.07) then it’s really easy to toggle on/off just the ones you need for an indicated degree of progress.  (also easy to do a “trail” where the tail follows along at “progress-0.25” or such, instead of staying stuck at 0, like if used as an indeterminate “busy” indicator rather than strict linear progress)

fwiw, hth

wow, this is great!

Perry

@schroederapps: Thanks a lot for sharing.

The progress ring is not working correctly anymore, if i change the resolution in the config.lua file like:

application = { content = { width = 800, height = 1200, scale = "letterbox", fps = 60, imageSuffix = { ["@2x"] = 1.3, }, }, }

Is it possible to fix this issue?

Hi @toga:

That’s strange - I see what you are talking about, when I add a config.lua file to my sample project, even when I try adjusting the settings, the progressRing doesn’t behave like it should. It likely has something to do with the way I generate a mask file - give me a little time and I’ll try to work out a solution. Thanks for finding this bug!

Just a follow-up: I’ve identified the problem. Since my module creates the mask image file procedurally, and mask files have very strict requirements (must be divisible by 4, must have at least a 3-pixel border, etc.), it gets tricky to create a proper mask image file on a device where the stage is being scaled (letterbox, zoomEven, etc.). To see what I mean @toga, try running your app in a simulator window sized exactly 800x1200 and everything should work as expected.

I’ve gotten things fixed, roughly, but I’m just doing a little more work to neaten up my code and make sure my solution is as flexible as it needs to be - I’m also working to fix an issue that arises if your progressRing is too large (and the mask image file gets stretched to the device’s native resolution because of a bug with the display.capture API). It’s unlikely that somebody would need a progressRing that large, but I want things to work in all cases if possible. I’ll post an updated version of the module later this week.

Thanks for your patience!

Hi @schroederapps,

thanks a lot for your work.

Hey everybody,

With thanks to toga for discovering a flaw in my module, I have corrected it so that the progressRing works as expected in all circumstances, including when content is being scaled (letterbox, zoomEven, zoomStretch), and if the progressRing is wider than the device’s screen width or height. You can download the corrected module here, and I’ve also updated the sample project available here. I’ve also updated the post above to include the corrected code.

Here’s what was happening: the progressRing utilizes a mask image to create the circular shape and “donut hole.” The mask image is procedurally generated, but previously it was not necessarily meeting the strict requirements for a mask image (height/width must be divisible by 4, and must have a 3-pixel black border on all sides) if the device’s width and height did not match the content width/height defined in your project’s config.lua. I’ve fixed this by making the mask file a fixed size that is not determined by the progressRing’s radius, but rather by the device’s screen size. Then the image mask is scaled appropriately to match up with the progressRing’s defined radius.

Thanks again toga for finding this flaw, and enjoy everybody!

Thanks a lot. I will try it tomorrow.

Really Really nice, good work!!!,

I will download this now!!

Great job

UPDATE: With daily build #2015.2544, Corona Labs fixed the finalize() bug that prevented objects’ finalize events from being triggered when their parent display group was removed. Woohoo! However, my progressRing module had a bug that only manifests itself now that finalize() is working again. I had utilized a workaround developed by @sergeylerg, but there was an error in my implementation of that fix that causes a crash when run on build 2015.2544 and later. I fixed that error by adding a 1ms timer when checking to see if finalize events are being triggered as they should. The finalize() fix is not necessary for up-to-date Corona builds, but I’m leaving it in for folks who might be running legacy Corona builds.

Please get the updated version of the progressRing module at http://www.jasonschroeder.com/2014/12/21/progress-ring-module-for-corona-sdk/

I’ve also updated the code that appears earlier in this thread to the latest version. Thanks Corona Labs for fixing that bug!

@horacebury

Thanks for your elegant solution. I found a minor issue with it: Sometimes the ring doesn’t go all the way to the end (i = 360).

Manually calling render() one last time in the onComplete-Function solves this problem.

Great work, I’m only wondering if the aliasing visible in the video is still there?

We have our own module using 4 masks and changing the corners position to simulate the progress, but the aliasing problem is not visible. I would gladly change to something less ‘hacky’ :wink:

Hi Krystian,

Unfortunately the method I use relied exclusively on Corona’s vector display objects (newCircle, newRect, newPolygon, etc.), which are not aliased (at least not yet). So if you need a softer edge, then I think your own workaround is probably the better bet, at least for now. 

I took a look, and I couldn’t find a feature request for anti-aliased vector objects. It’s probably worth making a request and trying to drum up some votes for it (I’d throw a couple votes at it). You can make a new request here: http://feedback.coronalabs.com/forums/188732-corona-sdk-feature-requests-feedback 

Thanks,

Jason

Hmmm

Have you seen this?

http://feedback.coronalabs.com/forums/188732-corona-sdk-feature-requests-feedback/suggestions/5034842-switchable-antialiasing-during-runtime

I’m now a bit confused about the aliasing.