import Toybox.Lang; import Toybox.Graphics; import Toybox.Math; // ============================================================================ // 布局与文字绘制 // ---------------------------------------------------------------------------- // 本文件里所有的数字都是**设计稿 500×500 画布上的坐标**,全部来自 // design/watchface-*.svg 的实测: // - 进度环的几何参数取自 design/rebuild.py(原作者从位图逆向出的常量) // - 各元素位置取自 SVG 描边路径的墨迹包围盒,不是目测的 // // 绘制时统一乘以 s = 屏宽 / 500,所以同一套数字可以覆盖 // 240px(fenix 7S)到 454px(venu 3)全部 17 款设备。 // // ⚠️ 使用前必须先由 view 的 onLayout 调用一次 init()。 // ============================================================================ module Layout { // ------------------------------- 进度环 ------------------------------- // 刻度是**等角宽的环形扇区**,不是从圆心发射的线段 —— 所以画的时候用四点 // 多边形填充,而不是 drawLine 描边。这点很关键:用线段画会导致刻度在内圈 // 挤、外圈疏,与设计稿对不上。 const R_IN = 231.09; // 刻度带内边缘半径 const R_OUT = 249.10; // 刻度带外边缘半径 const TOP_N = 21; // 每条顶弧的刻度数 const TOP_PH = 6.500; // 第 0 根刻度的角度(自 12 点起顺时针,度) const TOP_PITCH = 4.000; const TOP_HW = 1.554; // 刻度角宽 3.108° 的一半 const BOT_N = 21; // 每条底弧的刻度数 const BOT_PH = 6.005; // 底弧角度自 6 点起量 const BOT_PITCH = 3.000; const BOT_HW = 1.043; const DOT_RAD = 240.10; // 四个锚点圆点所在半径(正好是刻度带中线) const DOT_R = 9.00; const DOT_ANG = [0.0, 93.15, 180.0, 266.85]; // ------------------------------- 各元素 ------------------------------- const BATT_X = 192.70; // 电池外框左边缘 const BATT_W = 26.20; // 只是外框宽度,右侧的触点另算 const BATT_H = 17.81; const BATT_WALL = 2.20; // 外框线宽 const BATT_TW = 2.53; // 右侧触点宽 const BATT_TH = 9.20; const BATT_VX = 232.00; // 百分比文字左边缘 const BATT_CY = 53.20; const LEFT_X = 157.00; // 顶部两个数据位的中心 x(居中对齐) const RIGHT_X = 343.70; const ICON_CY = 89.50; // 与图标位图自身的中心对齐,别随意改 const VALUE_CY = 131.06; const TIME_CY = 221.10; const HOURS_R = 211.40; // 小时数字右边缘(右对齐基准) const MINUTES_L = 276.02; // 分钟数字左边缘(左对齐基准) const COLON_X = 237.88; const COLON_W = 24.24; const COLON_H = 21.30; const COLON_Y1 = 183.68; const COLON_Y2 = 234.57; const BAND_Y = 286.30; const BAND_H = 44.19; const BAND_CY = 308.10; const BT_CX = 36.88; // 蓝牙符号中心 const BT_CY = 306.99; const BT_HW = 8.89; const BT_HH = 12.92; const DOW_CX = 178.05; const MD_CX = 298.28; const AMPM_R = 465.89; const BOT_VAL_CY = 366.76; const BOT_LAB_CY = 410.88; const DIST_R = 234.30; // 左下格(数值+标签)的右对齐基准 const STEP_L = 267.40; // 右下格(数值+标签)的左对齐基准 // 设计稿里三档文字的**字面高度**(cap height,大写字母/数字的高度, // 不含上下留白)。字号选择就是按这个来匹配的。 const CAP_BAND = 25.34; // 日期带、电量百分比 const CAP_COMP = 31.50; // 四个数据位、底部行 const CAP_TIME = 98.70; // 时间大数字 // 数值最多占多宽,超了就降一档字号。 // 顶部两格是居中对齐,这个值是它能横跨的总宽度; // 底部两格是边对齐,各占表盘中间往外的一半。 // 有了这个,用户就算把 6 个汉字的长日期放进底部格也不会压到进度环上。 const TOP_MAX_W = 185.0; const BOT_MAX_W = 150.0; // ⚠️ Connect IQ 只暴露 ascent / descent / fontHeight,**没有字面高度接口**。 // 而 ascent 里包含了数字和大写字母永远用不到的变音符号空间,直接拿 ascent // 当字面高度会导致选出来的字号明显偏小(第一版就是这么错的,时间数字只有 // 设计稿的 57%)。 // // 下面两个比例是**实测**出来的:在模拟器里渲染,然后从截图上量墨迹高度。 // fenix847mm:FONT_XTINY ascent 29 → 大写实际高 22px → 0.76 // FONT_NUMBER_MILD ascent 82 → 数字实际高 58px → 0.71 // 各机型内置字体不完全相同(Roboto / RobotoCondensed / Yantramanav / // 各机型位图字体),比例会有几个百分点误差,可以接受。 const CAP_OF_TEXT = 0.76; const CAP_OF_NUM = 0.71; // ------------------------------- 运行时状态 ---------------------------- var scale = 1.0; // 设备像素 / 设计像素 var width = 0; var height = 0; var cx = 0; var cy = 0; var fontBand = Graphics.FONT_SMALL; var fontComp = Graphics.FONT_MEDIUM; var fontTime = Graphics.FONT_NUMBER_HOT; // 文字字号按从小到大排列,这样放不下的值可以沿着这张表往下逐档缩。 var textFonts = [Graphics.FONT_XTINY, Graphics.FONT_TINY, Graphics.FONT_SMALL, Graphics.FONT_MEDIUM, Graphics.FONT_LARGE]; function init(dc as Graphics.Dc) as Void { width = dc.getWidth(); height = dc.getHeight(); scale = width / 500.0; cx = width / 2; cy = height / 2; var nums = [Graphics.FONT_NUMBER_MILD, Graphics.FONT_NUMBER_MEDIUM, Graphics.FONT_NUMBER_HOT, Graphics.FONT_NUMBER_THAI_HOT]; fontBand = pickFont(textFonts, CAP_BAND * scale, CAP_OF_TEXT); fontComp = pickFont(textFonts, CAP_COMP * scale, CAP_OF_TEXT); fontTime = pickFont(nums, CAP_TIME * scale, CAP_OF_NUM); } function ready() as Boolean { return width > 0; } // 设计像素 → 设备像素。整个文件里凡是坐标都要过这一层。 function px(v as Numeric) as Number { return Math.round(v * scale).toNumber(); } function capHeight(font as Graphics.FontDefinition, ratio as Float) as Float { return Graphics.getFontAscent(font) * ratio; } // 选**字面高度最接近**目标的那一档,而不是「不超过目标的最大档」。 // 后者在设计尺寸恰好卡在两档字体之间时会白白小一号。 function pickFont(cands as Array, targetCap as Float, ratio as Float) as Graphics.FontDefinition { var best = cands[0]; var bestErr = -1.0; for (var i = 0; i < cands.size(); i++) { var err = capHeight(cands[i], ratio) - targetCap; if (err < 0) { err = -err; } if (bestErr < 0 || err < bestErr) { bestErr = err; best = cands[i]; } } return best; } // 值太长时(比如 12 个字符的自定义文字、6 个汉字的长日期)按设计稿字号会 // 冲出表盘。这里沿字号表往下找,直到能塞进槽位宽度为止。 function fitFont(dc as Graphics.Dc, str as String, font as Graphics.FontDefinition, maxDesignW as Float) as Graphics.FontDefinition { var limit = px(maxDesignW); var f = font; var i = textFonts.size() - 1; while (i >= 0) { if (textFonts[i] == f) { break; } i--; } if (i < 0) { return f; } // 不是文字字号表里的(比如数字字体),不动它 while (i >= 0) { if (dc.getTextWidthInPixels(str, textFonts[i]) <= limit) { return textFonts[i]; } i--; } return textFonts[0]; } // TEXT_JUSTIFY_VCENTER 居中的是**字体盒子**,而大写字母/数字只占据 // 「基线往上 capHeight」这一段,在盒子里是偏上的。 // 所以要往下推一个差值,才能让实际墨迹的中心落在 cy 上。 function inkY(dc as Graphics.Dc, font as Graphics.FontDefinition, cyDesign as Numeric, ratio as Float) as Number { var fh = dc.getFontHeight(font); var asc = Graphics.getFontAscent(font); return px(cyDesign) + ((fh / 2.0) - asc + capHeight(font, ratio) / 2.0).toNumber(); } function text(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric, font as Graphics.FontDefinition, str as String, just as Number, col as Number) as Void { dc.setColor(col, Graphics.COLOR_TRANSPARENT); dc.drawText(px(x), inkY(dc, font, cyDesign, CAP_OF_TEXT), font, str, just | Graphics.TEXT_JUSTIFY_VCENTER); } // 同上,但会在字符串超出槽位宽度时自动降字号。 function fittedText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric, font as Graphics.FontDefinition, str as String, just as Number, col as Number, maxDesignW as Float) as Void { text(dc, x, cyDesign, fitFont(dc, str, font, maxDesignW), str, just, col); } // Monkey C 没法给文字填渐变。这里的做法是:**同一串数字画 N 遍**,每遍用 // dc.setClip 把绘制限制在一条横向色带内,色带颜色取自主题的渐变停止点。 // // 首尾两条色带故意向外延伸到屏幕边缘,保证字形上下不会有没画到的缝隙 // (字体实际墨迹高度和我们估算的 capHeight 总有几像素出入)。 function gradText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric, font as Graphics.FontDefinition, str as String, just as Number, ramp as Array) as Void { var n = ramp.size(); var y = inkY(dc, font, cyDesign, CAP_OF_NUM); var ink = capHeight(font, CAP_OF_NUM).toNumber(); var top = px(cyDesign) - ink / 2; for (var i = 0; i < n; i++) { var y0 = top + (ink * i) / n; var y1 = top + (ink * (i + 1)) / n; if (i == 0) { y0 = 0; } if (i == n - 1) { y1 = height; } if (y1 <= y0) { continue; } dc.setClip(0, y0, width, y1 - y0); dc.setColor(ramp[i], Graphics.COLOR_TRANSPARENT); dc.drawText(px(x), y, font, str, just | Graphics.TEXT_JUSTIFY_VCENTER); } dc.clearClip(); } // 设计稿的角度自 12 点起顺时针为正;屏幕 y 轴向下为正,所以是 cy - cos。 function polarX(ang as Float, r as Float) as Number { return Math.round(cx + Math.sin(Math.toRadians(ang)) * r).toNumber(); } function polarY(ang as Float, r as Float) as Number { return Math.round(cy - Math.cos(Math.toRadians(ang)) * r).toNumber(); } function line(dc as Graphics.Dc, x1 as Numeric, y1 as Numeric, x2 as Numeric, y2 as Numeric) as Void { dc.drawLine(px(x1), px(y1), px(x2), px(y2)); } // 线宽在小屏上会被 round 成 0 直接消失,这里兜一个最小值 2。 function pen(v as Numeric) as Number { var w = px(v); return w < 2 ? 2 : w; } }