-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2953 from thyttan/ui-slider-lib
[Slider] New library for input via sliders
- Loading branch information
Showing
2 changed files
with
339 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,233 @@ | ||
/* Copyright (c) 2023 Bangle.js contributors. See the file LICENSE for copying permission. */ | ||
|
||
// At time of writing in October 2023 this module is new and things are more likely to change during the coming weeks than in a month or two. | ||
|
||
// See Slider.md for documentation | ||
|
||
/* Minify to 'Slider.min.js' by: // TODO: Should we do this for Slider module? | ||
* checking out: https://github.com/espruino/EspruinoDocs | ||
* run: ../EspruinoDocs/bin/minify.js modules/Slider.js modules/Slider.min.js | ||
*/ | ||
|
||
exports.create = function(cb, conf) { | ||
|
||
const R = Bangle.appRect; | ||
|
||
// Empty function added to cb if it's undefined. | ||
if (!cb) cb = ()=>{}; | ||
|
||
let o = {}; | ||
o.v = {}; // variables go here. | ||
o.f = {}; // functions go here. | ||
|
||
// Default configuration for the indicator, modified by parameter `conf`: | ||
o.c = Object.assign({ // constants go here. | ||
initLevel:0, | ||
horizontal:false, | ||
xStart:R.x2-R.w/4-4, | ||
width:R.w/4, | ||
yStart:R.y+4, | ||
height:R.h-10, | ||
steps:30, | ||
|
||
dragableSlider:true, | ||
dragRect:R, | ||
mode:"incr", | ||
oversizeR:0, | ||
oversizeL:0, | ||
propagateDrag:false, | ||
timeout:1, | ||
|
||
drawableSlider:true, | ||
colorFG:g.theme.fg2, | ||
colorBG:g.theme.bg2, | ||
rounded:true, | ||
outerBorderSize:Math.round(2*R.w/176), // 176 is the # of pixels in a row on the Bangle.js 2's screen and typically also its app rectangles, used here to rescale to whatever pixel count is on the current app rectangle. | ||
innerBorderSize:Math.round(2*R.w/176), | ||
|
||
autoProgress:false, | ||
},conf); | ||
|
||
// If borders are bigger than the configured width, make them smaller to avoid glitches. | ||
while (o.c.width <= 2*(o.c.outerBorderSize+o.c.innerBorderSize)) { | ||
o.c.outerBorderSize--; | ||
o.c.innerBorderSize--; | ||
} | ||
o.c.outerBorderSize = Math.max(0,o.c.outerBorderSize); | ||
o.c.innerBorderSize = Math.max(0,o.c.innerBorderSize); | ||
|
||
let totalBorderSize = o.c.outerBorderSize + o.c.innerBorderSize; | ||
o.c.rounded = o.c.rounded?o.c.width/2:0; | ||
if (o.c.rounded) o.c._rounded = (o.c.width-2*totalBorderSize)/2; | ||
|
||
o.c.STEP_SIZE = ((o.c.height-2*totalBorderSize)-(!o.c.rounded?0:(2*o.c._rounded)))/o.c.steps; | ||
|
||
// If horizontal, flip things around. | ||
if (o.c.horizontal) { | ||
let mediator = o.c.xStart; | ||
o.c.xStart = o.c.yStart; | ||
o.c.yStart = mediator; | ||
mediator = o.c.width; | ||
o.c.width = o.c.height; | ||
o.c.height = mediator; | ||
delete mediator; | ||
} | ||
|
||
// Make room for the border. Underscore indicates the area for the actual indicator bar without borders. | ||
o.c._xStart = o.c.xStart + totalBorderSize; | ||
o.c._width = o.c.width - 2*totalBorderSize; | ||
o.c._yStart = o.c.yStart + totalBorderSize; | ||
o.c._height = o.c.height - 2*totalBorderSize; | ||
|
||
// Add a rectangle object with x, y, x2, y2, w and h values. | ||
o.c.r = {x:o.c.xStart, y:o.c.yStart, x2:o.c.xStart+o.c.width, y2:o.c.yStart+o.c.height, w:o.c.width, h:o.c.height}; | ||
|
||
// Initialize the level | ||
o.v.level = o.c.initLevel; | ||
|
||
// Only add interactivity if wanted. | ||
if (o.c.dragableSlider) { | ||
|
||
let useMap = (o.c.mode==="map"||o.c.mode==="mapincr")?true:false; | ||
let useIncr = (o.c.mode==="incr"||o.c.mode==="mapincr")?true:false; | ||
|
||
const Y_MAX = g.getHeight()-1; // TODO: Should this take users screen calibration into account? | ||
|
||
o.v.ebLast = 0; | ||
o.v.dy = 0; | ||
|
||
o.f.wasOnDragRect = (exFirst, eyFirst)=>{ | ||
"ram"; | ||
return exFirst>o.c.dragRect.x && exFirst<o.c.dragRect.x2 && eyFirst>o.c.dragRect.y && eyFirst<o.c.dragRect.y2; | ||
}; | ||
|
||
o.f.wasOnIndicator = (exFirst)=>{ | ||
"ram"; | ||
if (!o.c.horizontal) return exFirst>o.c._xStart-o.c.oversizeL*o.c._width && exFirst<o.c._xStart+o.c._width+o.c.oversizeR*o.c._width; | ||
if (o.c.horizontal) return exFirst>o.c._yStart-o.c.oversizeL*o.c._height && exFirst<o.c._yStart+o.c._height+o.c.oversizeR*o.c._height; | ||
}; | ||
|
||
// Function to pass to `Bangle.on('drag', )` | ||
o.f.dragSlider = e=>{ | ||
"ram"; | ||
if (o.v.ebLast==0) { | ||
exFirst = o.c.horizontal?e.y:e.x; | ||
eyFirst = o.c.horizontal?e.x:e.y; | ||
} | ||
|
||
// Only react if on allowed area. | ||
if (o.f.wasOnDragRect(exFirst, eyFirst)) { | ||
o.v.dragActive = true; | ||
if (!o.c.propagateDrag) E.stopEventPropagation&&E.stopEventPropagation(); | ||
|
||
if (o.v.timeoutID) {clearTimeout(o.v.timeoutID); o.v.timeoutID = undefined;} | ||
if (e.b==0 && !o.v.timeoutID && (o.c.timeout || o.c.timeout===0)) o.v.timeoutID = setTimeout(o.f.remove, 1000*o.c.timeout); | ||
|
||
if (useMap && o.f.wasOnIndicator(exFirst)) { // If draging starts on the indicator, adjust one-to-one. | ||
|
||
let input = !o.c.horizontal? | ||
Math.min((Y_MAX-e.y)-o.c.yStart-3*o.c.rounded/4, o.c.height): | ||
Math.min(e.x-o.c.xStart-3*o.c.rounded/4, o.c.width); | ||
input = Math.round(input/o.c.STEP_SIZE); | ||
|
||
o.v.level = Math.min(Math.max(input,0),o.c.steps); | ||
|
||
o.v.cbObj = {mode:"map", value:o.v.level}; | ||
|
||
} else if (useIncr) { // Heavily inspired by "updown" mode of setUI. | ||
|
||
o.v.dy += o.c.horizontal?-e.dx:e.dy; | ||
//if (!e.b) o.v.dy=0; | ||
|
||
while (Math.abs(o.v.dy)>32) { | ||
let incr; | ||
if (o.v.dy>0) { o.v.dy-=32; incr = 1;} | ||
else { o.v.dy+=32; incr = -1;} | ||
Bangle.buzz(20); | ||
|
||
o.v.level = Math.min(Math.max(o.v.level-incr,0),o.c.steps); | ||
|
||
o.v.cbObj = {mode:"incr", value:incr}; | ||
} | ||
} | ||
if (o.v.cbObj && (o.v.level!==o.v.prevLevel||o.v.level===0||o.v.level===o.c.steps)) { | ||
cb(o.v.cbObj.mode, o.v.cbObj.value); | ||
o.f.draw&&o.f.draw(o.v.level); | ||
} | ||
o.v.cbObj = null; | ||
o.v.prevLevel = o.v.level; | ||
o.v.ebLast = e.b; | ||
} | ||
}; | ||
|
||
// Cleanup. | ||
o.f.remove = ()=> { | ||
Bangle.removeListener('drag', o.f.dragSlider); | ||
o.v.dragActive = false; | ||
o.v.timeoutID = undefined; | ||
cb("remove", o.v.level); | ||
}; | ||
} | ||
|
||
// Add standard slider graphics only if wanted. | ||
if (o.c.drawableSlider) { | ||
|
||
// Function for getting the indication bars size. | ||
o.f.updateBar = (levelHeight)=>{ | ||
"ram"; | ||
if (!o.c.horizontal) return {x:o.c._xStart,y:o.c._yStart+o.c._height-levelHeight,w:o.c._width,y2:o.c._yStart+o.c._height,r:o.c.rounded}; | ||
if (o.c.horizontal) return {x:o.c._xStart,y:o.c._yStart,w:levelHeight,h:o.c._height,r:o.c.rounded}; | ||
}; | ||
|
||
o.c.borderRect = {x:o.c._xStart-totalBorderSize,y:o.c._yStart-totalBorderSize,w:o.c._width+2*totalBorderSize,h:o.c._height+2*totalBorderSize,r:o.c.rounded}; | ||
|
||
o.c.hollowRect = {x:o.c._xStart-o.c.innerBorderSize,y:o.c._yStart-o.c.innerBorderSize,w:o.c._width+2*o.c.innerBorderSize,h:o.c._height+2*o.c.innerBorderSize,r:o.c.rounded}; | ||
|
||
// Standard slider drawing method. | ||
o.f.draw = (level)=>{ | ||
"ram"; | ||
|
||
g.setColor(o.c.colorFG).fillRect(o.c.borderRect). // To get outer border... | ||
setColor(o.c.colorBG).fillRect(o.c.hollowRect). // ... and here it's made hollow. | ||
setColor(0==level?o.c.colorBG:o.c.colorFG).fillRect(o.f.updateBar((!o.c.rounded?0:(2*o.c._rounded))+level*o.c.STEP_SIZE)); // Here the bar is drawn. | ||
if (o.c.rounded && level===0) { // Hollow circle indicates level zero when slider is rounded. | ||
g.setColor(o.c.colorFG).fillCircle(o.c._xStart+o.c._rounded, o.c._yStart+o.c._height-o.c._rounded, o.c._rounded). | ||
setColor(o.c.colorBG).fillCircle(o.c._xStart+o.c._rounded, o.c._yStart+o.c._height-o.c._rounded, o.c._rounded-o.c.outerBorderSize); | ||
} | ||
}; | ||
} | ||
|
||
// Add logic for auto progressing the slider only if wanted. | ||
if (o.c.autoProgress) { | ||
o.f.autoUpdate = ()=>{ | ||
o.v.level = o.v.autoInitLevel + Math.round((Date.now()-o.v.autoInitTime)/1000); | ||
if (o.v.level>o.c.steps) o.v.level=o.c.steps; | ||
cb("auto", o.v.level); | ||
o.f.draw&&o.f.draw(o.v.level); | ||
if (o.v.level==o.c.steps) {o.f.stopAutoUpdate();} | ||
}; | ||
o.f.initAutoValues = ()=>{ | ||
o.v.autoInitTime=Date.now(); | ||
o.v.autoInitLevel=o.v.level; | ||
}; | ||
o.f.startAutoUpdate = (intervalSeconds)=>{ | ||
if (!intervalSeconds) intervalSeconds = 1; | ||
o.f.stopAutoUpdate(); | ||
o.f.initAutoValues(); | ||
o.f.draw&&o.f.draw(o.v.level); | ||
o.v.autoIntervalID = setInterval(o.f.autoUpdate,1000*intervalSeconds); | ||
}; | ||
o.f.stopAutoUpdate = ()=>{ | ||
if (o.v.autoIntervalID) { | ||
clearInterval(o.v.autoIntervalID); | ||
o.v.autoIntervalID = undefined; | ||
} | ||
o.v.autoInitLevel = undefined; | ||
o.v.autoInitTime = undefined; | ||
}; | ||
} | ||
|
||
return o; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
Slider Library | ||
============== | ||
|
||
*At time of writing in October 2023 this module is new and things are more likely to change during the coming weeks than in a month or two.* | ||
|
||
> Take a look at README.md for hints on developing with this library. | ||
Usage | ||
----- | ||
|
||
```js | ||
var Slider = require("Slider"); | ||
var slider = Slider(callbackFunction, configObject); | ||
|
||
Bangle.on("drag", slider.f.dragSlider); | ||
|
||
// If the slider should take precedent over other drag handlers use (fw2v18 and up): | ||
// Bangle.prependListener("drag", slider.f.dragSlider); | ||
``` | ||
|
||
`callbackFunction` (`cb`) (first argument) determines what `slider` is used for. `slider` will pass two arguments, `mode` and `feedback` (`fb`), into `callbackFunction` (if `slider` is interactive or auto progressing). The different `mode`/`feedback` combinations to expect are: | ||
- `"map", o.v.level` | current level when interacting by mapping interface. | ||
- `"incr", incr` | where `incr` == +/-1, when interacting by incrementing interface. | ||
- `"remove", o.v.level` | last level when the slider times out. | ||
- `"auto", o.v.level` | when auto progressing. | ||
|
||
`configObject` (`conf`) (second argument, optional) has the following defaults: | ||
|
||
```js | ||
R = Bangle.appRect; // For use when determining defaults below. | ||
|
||
{ | ||
initLevel: 0, // The level to initialize the slider with. | ||
horizontal: false, // Slider should be horizontal? | ||
xStart: R.x2-R.w/4-4, // Leftmost x-coordinate. (Uppermost y-coordinate if horizontal) | ||
width: R.w/4, // Width of the slider. (Height if horizontal) | ||
yStart: R.y+4, // Uppermost y-coordinate. (Rightmost x-coordinate if horizontal) | ||
height: R.h-10, // Height of the slider. (Width if horizontal) | ||
steps: 30, // Number of discrete steps of the slider. | ||
|
||
dragableSlider: true, // Should supply the sliders standard interaction mechanisms? | ||
dragRect: R, // Accept input within this rectangle. | ||
mode: "incr", // What mode of draging to use: "map", "incr" or "mapincr". | ||
oversizeR: 0, // Determines if the mapping area should be extend outside the indicator (Right/Up). | ||
oversizeL: 0, // Determines if the mapping area should be extend outside the indicator (Left/Down). | ||
propagateDrag: false, // Pass the drag event on down the handler chain? | ||
timeout: 1, // Seconds until the slider times out. If set to `false` the slider stays active. The callback function is responsible for repainting over the slider graphics. | ||
|
||
drawableSlider: true, // Should supply the sliders standard drawing mechanism? | ||
colorFG: g.theme.fg2, // Foreground color. | ||
colorBG: g.theme.bg2, // Background color. | ||
rounded: true, // Slider should have rounded corners? | ||
outerBorderSize: Math.round(2*R.w/176), // The size of the visual border. Scaled in relation to Bangle.js 2 screen width/typical app rectangle widths. | ||
innerBorderSize: Math.round(2*R.w/176), // The distance between visual border and the slider. | ||
|
||
autoProgress: false, // The slider should be able to progress automatically? | ||
} | ||
``` | ||
|
||
A slider initiated in the Web IDE terminal window reveals its internals to a degree: | ||
```js | ||
slider = require("Slider").create(()=>{}, {autoProgress:true}) | ||
={ | ||
v: { level: 0, ebLast: 0, dy: 0 }, | ||
f: { | ||
wasOnDragRect: function (exFirst,eyFirst) { ... }, // Used internally. | ||
wasOnIndicator: function (exFirst) { ... }, // Used internally. | ||
dragSlider: function (e) { ... }, // The drag handler. | ||
remove: function () { ... }, // Used to remove the drag handler and run the callback function. | ||
updateBar: function (levelHeight) { ... }, // Used internally to get the variable height rectangle for the indicator. | ||
draw: function (level) { ... }, // Draw the slider with the supplied level. | ||
autoUpdate: function () { ... }, // Used to update the slider when auto progressing. | ||
initAutoValues: function () { ... }, // Used internally. | ||
startAutoUpdate: function (intervalSeconds) { ... }, // `intervalSeconds` defaults to 1 second if it's not supplied when `startAutoUpdate` is called. | ||
stopAutoUpdate: function () { ... } // Stop auto progressing and clear some related values. | ||
}, | ||
c: { initLevel: 0, horizontal: false, xStart: 127, width: 44, | ||
yStart: 4, height: 166, steps: 30, dragableSlider: true, | ||
dragRect: { x: 0, y: 0, w: 176, h: 176, | ||
x2: 175, y2: 175 }, | ||
mode: "incr", | ||
oversizeR: 0, oversizeL: 0, propagateDrag: false, timeout: 1, drawableSlider: true, | ||
colorFG: 63488, colorBG: 8, rounded: 22, outerBorderSize: 2, innerBorderSize: 2, | ||
autoProgress: true, _rounded: 18, STEP_SIZE: 4.06666666666, _xStart: 131, _width: 36, | ||
_yStart: 8, _height: 158, | ||
r: { x: 127, y: 4, x2: 171, y2: 170, | ||
w: 44, h: 166 }, | ||
borderRect: { x: 127, y: 4, w: 44, h: 166, | ||
r: 22 }, | ||
hollowRect: { x: 129, y: 6, w: 40, h: 162, | ||
r: 22 } | ||
} | ||
} | ||
> | ||
``` | ||
Tips | ||
---- | ||
|
||
You can implement custom graphics for a slider in the `callbackFunction`. The slider test app mentioned in the links below do this. To draw on top of the included slider graphics you need to wrap the drawing code in a timeout somewhat like so: `setTimeout(drawingFunction,0,fb)` (see [`setTimeout` documentation](https://www.espruino.com/Reference#l__global_setTimeout)). | ||
This comment has been minimized.
Sorry, something went wrong.
This comment has been minimized.
Sorry, something went wrong.
gfwilliams
Author
Member
|
||
|
||
Links | ||
----- | ||
|
||
There is a [slider test app on thyttan's personal app loader](https://thyttan.github.io/BangleApps/?q=slidertest) (at time of writing). Looking at [its code](https://github.com/thyttan/BangleApps/blob/ui-slider-lib/apps/slidertest/app.js) is a good way to see how the slider is used in app development. | ||
|
||
The version of [Remote for Spotify on thyttan's personal app loader](https://thyttan.github.io/BangleApps/?q=spotrem) (at time of writing) also utilizes the `Slider` module. Here is [the code](https://github.com/thyttan/BangleApps/blob/ui-slider-lib/apps/spotrem/app.js). |
@gfwilliams, a question regarding this:
The
setTimeout
here could be made unnecessary if we run the callback after drawing. The current order of first callback then draw is chosen to ape how e.g. menus do it, and to let callbacks run ASAP.But I more and more get the feeling it would be smarter to swap it around to first draw then do callback, avoiding the need for
setTimeout
.What do you think? (I realize this is splitting hairs...)