├── demo ├── .gitignore ├── README.md ├── main.js ├── strings │ ├── zh-Hans.strings │ └── en.strings ├── config.json ├── LICENSE ├── scripts │ ├── ui │ │ ├── factory.js │ │ ├── home.js │ │ └── list.js │ └── app.js └── setting.json ├── docs ├── controller.md ├── toast.md ├── README.md ├── kernel.md └── setting.md ├── tools ├── README.md ├── matrix.js └── sloarToLunar.js ├── src ├── version.js ├── validation-error.js ├── tasks.js ├── controller.js ├── fixed-footer-view.js ├── logger.js ├── navigation-view │ ├── view-controller.js │ ├── search-bar.js │ ├── navigation-view.js │ ├── navigation-bar-items.js │ └── navigation-bar.js ├── plist.js ├── ui-loading.js ├── l10n.js ├── toast.js ├── request.js ├── file-storage.js ├── view.js ├── kernel.js ├── sheet.js ├── matrix.js ├── webdav.js ├── ui-kit.js ├── file-manager.js ├── alert.js ├── tab-bar.js └── setting │ └── setting.js ├── .gitignore ├── easyjsbox.code-workspace ├── package.json ├── README.md └── LICENSE /demo/.gitignore: -------------------------------------------------------------------------------- 1 | .output 2 | .idea -------------------------------------------------------------------------------- /docs/controller.md: -------------------------------------------------------------------------------- 1 | # Controller 2 | 3 | 暂无 4 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # Demo 2 | 3 | 本项目为 EasyJsBox 使用的示例项目。 -------------------------------------------------------------------------------- /tools/README.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | > 单独使用,与 EasyJsBox 无关联 -------------------------------------------------------------------------------- /src/version.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | VERSION: "1.4.6" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .output 2 | .idea 3 | .vscode 4 | node_modules 5 | .parcel-cache -------------------------------------------------------------------------------- /demo/main.js: -------------------------------------------------------------------------------- 1 | // run app 2 | const app = require("./scripts/app") 3 | app.run() -------------------------------------------------------------------------------- /easyjsbox.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } -------------------------------------------------------------------------------- /demo/strings/zh-Hans.strings: -------------------------------------------------------------------------------- 1 | "HOME" = "首页"; 2 | "LIST" = "列表"; 3 | "HOME_PLUS_BUTTON_MESSAGE" = "这是一个按钮,点击后会出现相应的动画。"; 4 | "HELLO_WORLD" = "你好,世界!"; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "MIT", 3 | "scripts": { 4 | "build": "node build.js" 5 | }, 6 | "dependencies": { 7 | "esbuild": "0.18.17" 8 | } 9 | } -------------------------------------------------------------------------------- /demo/strings/en.strings: -------------------------------------------------------------------------------- 1 | "HOME" = "Home"; 2 | "LIST" = "List"; 3 | "HOME_PLUS_BUTTON_MESSAGE" = "This is a button, and the corresponding animation will appear after clicking it."; 4 | "HELLO_WORLD" = "Hello World!"; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EasyJsBox 2 | 3 | > 一个简单的JSBox应用框架 4 | 5 | 可以在安装脚本后将其安装为 JSBox 模块,安装为模块后将: 6 | 7 | - 可在脚本中直接使用,如 `const { VERSION, Kernel } = require('easy-jsbox')` 8 | - 无法使用“检查更新”功能 9 | 10 | [相关文档](./docs/README.md) 11 | -------------------------------------------------------------------------------- /src/validation-error.js: -------------------------------------------------------------------------------- 1 | class ValidationError extends Error { 2 | constructor(parameter, type) { 3 | super(`The type of the parameter '${parameter}' must be '${type}'`) 4 | this.name = "ValidationError" 5 | } 6 | } 7 | 8 | module.exports = { 9 | ValidationError 10 | } 11 | -------------------------------------------------------------------------------- /docs/toast.md: -------------------------------------------------------------------------------- 1 | # Toast 2 | 3 | > `Toast` 提供短暂的信息展示,用户主动点击会移除该 `Toast`。 4 | 5 | ## Methods 6 | 7 | - `Toast.info(message, opts = {})` 8 | - `Toast.success(message, opts = {})` 9 | - `Toast.warning(message, opts = {})` 10 | - `Toast.error(message, opts = {})` 11 | 12 | `opts` 结构如下: 13 | 14 | ```js 15 | const opts = { 16 | displayTime = 3, // 显示时间,秒 17 | labelLines = 2, // 显示行数 18 | font = $font("default", 26) // 字体 19 | } 20 | ``` 21 | -------------------------------------------------------------------------------- /demo/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "name": "Demo", 4 | "url": "", 5 | "version": "1.0.0", 6 | "author": "ipuppet", 7 | "website": "https://blog.ipuppet.top", 8 | "module": false 9 | }, 10 | "settings": { 11 | "theme": "auto", 12 | "minSDKVer": "2.13.0", 13 | "minOSVer": "14.0.0", 14 | "idleTimerDisabled": false, 15 | "autoKeyboardEnabled": true, 16 | "keyboardToolbarEnabled": true, 17 | "rotateDisabled": false 18 | } 19 | } -------------------------------------------------------------------------------- /src/tasks.js: -------------------------------------------------------------------------------- 1 | class Tasks { 2 | #tasks = {} 3 | 4 | /** 5 | * 6 | * @param {Function} task 7 | * @param {number} delay 单位 s 8 | * @returns 9 | */ 10 | addTask(task, delay = 0) { 11 | const uuid = $text.uuid 12 | this.#tasks[uuid] = $delay(delay, async () => { 13 | await task() 14 | delete this.#tasks[uuid] 15 | }) 16 | return uuid 17 | } 18 | 19 | cancelTask(id) { 20 | this.#tasks[id].cancel() 21 | } 22 | 23 | clearTasks() { 24 | Object.values(this.#tasks).forEach(task => task.cancel()) 25 | } 26 | } 27 | 28 | module.exports = { Tasks } 29 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # EasyJsBox 2 | 3 | > 一个简单的JSBox应用框架 4 | > 框架为模块化设计,可按照自身需求灵活增减模块。 5 | 6 | ## 开始使用 7 | 8 | ```js 9 | const { Kernel } = require("./easy-jsbox") 10 | 11 | class AppKernel extends Kernel { 12 | constructor() { 13 | super() 14 | this.query = $context.query 15 | } 16 | } 17 | 18 | const kernel = new AppKernel() 19 | kernel.useJsboxNav() 20 | kernel.UIRender({ 21 | views: [{ 22 | type: "label", 23 | props: { 24 | text: "Hello World!" 25 | }, 26 | layout: $layout.fill 27 | }] 28 | }) 29 | ``` 30 | 31 | ## 文档索引 32 | 33 | [Kernel](./kernel.md) 34 | 35 | [Controller](./controller.md) 36 | 37 | [Setting](./setting.md) 38 | -------------------------------------------------------------------------------- /src/controller.js: -------------------------------------------------------------------------------- 1 | class Controller { 2 | events = {} 3 | 4 | setEvents(events) { 5 | Object.keys(events).forEach(event => this.setEvent(event, events[event])) 6 | return this 7 | } 8 | 9 | setEvent(event, callback) { 10 | this.events[event] = callback 11 | return this 12 | } 13 | 14 | appendEvent(event, callback) { 15 | const old = this.events[event] 16 | if (typeof old === "function") { 17 | this.events[event] = (...args) => { 18 | old(...args) 19 | callback(...args) 20 | } 21 | } else { 22 | this.setEvent(event, callback) 23 | } 24 | 25 | return this 26 | } 27 | 28 | callEvent(event, ...args) { 29 | if (typeof this.events[event] === "function") { 30 | this.events[event](...args) 31 | } 32 | } 33 | } 34 | 35 | module.exports = { 36 | Controller 37 | } 38 | -------------------------------------------------------------------------------- /src/fixed-footer-view.js: -------------------------------------------------------------------------------- 1 | const { View } = require("./view") 2 | const { UIKit } = require("./ui-kit") 3 | 4 | class FixedFooterView extends View { 5 | height = 60 6 | 7 | getView() { 8 | this.type = "view" 9 | this.setProp("bgcolor", UIKit.primaryViewBackgroundColor) 10 | this.layout = (make, view) => { 11 | make.left.right.bottom.equalTo(view.super) 12 | make.top.equalTo(view.super.safeAreaBottom).offset(-this.height) 13 | } 14 | 15 | this.views = [ 16 | View.create({ 17 | props: this.props, 18 | views: this.views, 19 | layout: (make, view) => { 20 | make.left.right.top.equalTo(view.super) 21 | make.height.equalTo(this.height) 22 | } 23 | }) 24 | ] 25 | 26 | return this 27 | } 28 | } 29 | 30 | module.exports = { 31 | FixedFooterView 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Samuel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /demo/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 ipuppet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/kernel.md: -------------------------------------------------------------------------------- 1 | # Kernel 2 | 3 | > Kernel 类应该是一个应用程序的核心,应该只有全局唯一的实例 4 | 5 | ## Property 6 | 7 | - `this.startTime: Number` 8 | 9 | 应用启动时的时间戳 10 | 11 | ## Methods 12 | 13 | - `useJsboxNav()` 14 | 15 | 使用 JSBox 的 nav 样式。若不调用则默认隐藏。 16 | 17 | - `setTitle(title)` 18 | 19 | 设置 JSBox nav 标题,一般与 useJsboxNav 配合使用,可动态更改 JSBox nav 的标题。 20 | 21 | **Parameter** 22 | 23 | - title: String 标题 24 | 25 | - `setNavButtons(buttons)` 26 | 27 | 设置 JSBox nav 按钮,一般与 useJsboxNav 配合使用。需要注意,该按钮无法动态更改。 28 | 29 | **Parameter** 30 | 31 | - buttons: Array 按钮数组,与 JSBox 原生格式一致。 32 | 33 | - `UIRender(view)` 34 | 35 | 渲染视图,应该始终通过该方法进行渲染。 36 | 37 | 该方法将自动注册事件 `"interfaceOrientationEvent"` 用以监听屏幕方向: 38 | 39 | ```js 40 | $app.notify({ 41 | name: "interfaceOrientationEvent", 42 | object: { 43 | statusBarOrientation: UIKit.statusBarOrientation, 44 | isHorizontal: UIKit.isHorizontal 45 | } 46 | }) 47 | ``` 48 | 49 | **Parameter** 50 | 51 | - view: Object 视图对象,与 JSBox 原生格式一致。 52 | 53 | - `async checkUpdate(callback)` 54 | 55 | 检查框架自身是否需要更新 56 | 57 | **Parameter** 58 | 59 | - callback(newScriptContent): Function 60 | 61 | 该回调函数仅在需要更新时才被调用,否则该函数静默。 62 | 63 | **Parameter** 64 | 65 | - newScriptContent: String 从 Github 获取的最新脚本内容。 66 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("./file-storage").FileStorage} FileStorage 3 | */ 4 | 5 | class Logger { 6 | static level = { 7 | info: "info", 8 | warn: "warn", 9 | error: "error" 10 | } 11 | 12 | writer 13 | fsLevels = [Logger.level.error] 14 | 15 | /** 16 | * @param {Array} levels 17 | */ 18 | printToFile(levels) { 19 | this.fsLevels = levels 20 | } 21 | 22 | /** 23 | * @param {FileStorage} fs 24 | * @param {string} path 25 | */ 26 | setWriter(fs, path) { 27 | this.writer = msg => { 28 | if (fs.exists(path)) { 29 | let old = fs.readSync(path)?.string ?? "" 30 | msg = old + msg 31 | } 32 | fs.writeSync(path, $data({ string: msg })) 33 | } 34 | } 35 | 36 | format(msg, level) { 37 | const time = new Date().toUTCString() 38 | return `${time} [${level.toUpperCase()}] ${msg}\n` 39 | } 40 | 41 | log(msg, level) { 42 | if (this.writer && this.fsLevels.includes(level)) { 43 | this.writer(this.format(msg, level)) 44 | } 45 | // 控制台不格式化 46 | if ($app.isDebugging) console[level](msg) 47 | } 48 | info(msg) { 49 | this.log(msg, Logger.level.info) 50 | } 51 | warn(msg) { 52 | this.log(msg, Logger.level.warn) 53 | } 54 | error(msg) { 55 | this.log(msg, Logger.level.error) 56 | } 57 | } 58 | 59 | module.exports = { 60 | Logger 61 | } 62 | -------------------------------------------------------------------------------- /src/navigation-view/view-controller.js: -------------------------------------------------------------------------------- 1 | const { Controller } = require("../controller") 2 | 3 | /** 4 | * @typedef {import("./navigation-view").NavigationView} NavigationView 5 | */ 6 | 7 | /** 8 | * @property {function(NavigationView)} ViewController.events.onChange 9 | */ 10 | class ViewController extends Controller { 11 | /** 12 | * @type {NavigationView[]} 13 | */ 14 | #navigationViews = [] 15 | 16 | /** 17 | * @param {NavigationView} navigationView 18 | */ 19 | #onPop(navigationView) { 20 | navigationView.callEvent("onPop") 21 | this.callEvent("onPop", navigationView) // 被弹出的对象 22 | this.#navigationViews.pop() 23 | } 24 | 25 | /** 26 | * push 新页面 27 | * @param {NavigationView} navigationView 28 | */ 29 | push(navigationView) { 30 | const parent = this.#navigationViews[this.#navigationViews.length - 1] 31 | navigationView.navigationBarItems.addPopButton(parent?.navigationBar.title) 32 | this.#navigationViews.push(navigationView) 33 | $ui.push({ 34 | props: { 35 | statusBarStyle: 0, 36 | navBarHidden: true 37 | }, 38 | events: { 39 | disappeared: () => { 40 | this.#onPop(navigationView) 41 | } 42 | }, 43 | views: [navigationView.getPage().definition], 44 | layout: $layout.fill 45 | }) 46 | } 47 | } 48 | 49 | module.exports = { 50 | ViewController 51 | } 52 | -------------------------------------------------------------------------------- /demo/scripts/ui/factory.js: -------------------------------------------------------------------------------- 1 | const { TabBarController } = require("../libs/easy-jsbox") 2 | 3 | class Factory { 4 | constructor(kernel) { 5 | this.kernel = kernel 6 | this.tabBarController = new TabBarController() 7 | } 8 | 9 | home() { 10 | const InterfaceUI = require("./home") 11 | const interfaceUi = new InterfaceUI(this.kernel) 12 | return interfaceUi.getPage() 13 | } 14 | 15 | list() { 16 | const InterfaceUI = require("./list") 17 | const interfaceUi = new InterfaceUI(this.kernel) 18 | return interfaceUi.getPage() 19 | } 20 | 21 | setting() { 22 | return this.kernel.setting.getPage() 23 | } 24 | 25 | /** 26 | * 渲染页面 27 | */ 28 | render() { 29 | this.tabBarController 30 | .setPages({ 31 | home: this.home(), 32 | list: this.list(), 33 | setting: this.setting() 34 | }) 35 | .setCells({ 36 | home: { 37 | icon: ["house", "house.fill"], 38 | title: $l10n("HOME") 39 | }, 40 | list: { 41 | icon: "doc.plaintext", 42 | title: $l10n("LIST") 43 | }, 44 | setting: { 45 | icon: "gear", 46 | title: $l10n("SETTING") 47 | } 48 | }) 49 | this.kernel.UIRender(this.tabBarController.generateView().definition) 50 | } 51 | } 52 | 53 | module.exports = Factory 54 | -------------------------------------------------------------------------------- /demo/scripts/ui/home.js: -------------------------------------------------------------------------------- 1 | const { NavigationView, NavigationBar } = require("../libs/easy-jsbox") 2 | 3 | class HomeUI { 4 | constructor(kernel) { 5 | this.kernel = kernel 6 | } 7 | 8 | getPage() { 9 | // 初始化页面控制器 10 | const navigationView = new NavigationView() 11 | // 设置导航条元素 12 | navigationView.navigationBarTitle($l10n("HOME")) 13 | navigationView.navigationBar.setLargeTitleDisplayMode(NavigationBar.LargeTitleDisplayModeAlways) // 一直显示大标题 14 | navigationView.navigationBarItems.setRightButtons([ 15 | { 16 | symbol: "plus.circle", 17 | tapped: animate => { 18 | animate.start() 19 | $ui.alert({ 20 | title: $l10n("HOME_PLUS_BUTTON_MESSAGE"), 21 | actions: [ 22 | { 23 | title: "OK", 24 | handler: () => { 25 | animate.done() 26 | } 27 | }, 28 | { 29 | title: "Cancel", 30 | handler: () => { 31 | animate.cancel() 32 | } 33 | } 34 | ] 35 | }) 36 | } 37 | } 38 | ]) 39 | // 添加视图 40 | navigationView.setView({ 41 | type: "markdown", 42 | props: { 43 | content: `## ${$l10n("HELLO_WORLD")}` 44 | }, 45 | layout: $layout.fill 46 | }) 47 | return navigationView.getPage() 48 | } 49 | } 50 | 51 | module.exports = HomeUI 52 | -------------------------------------------------------------------------------- /src/plist.js: -------------------------------------------------------------------------------- 1 | class Plist { 2 | constructor(content) { 3 | this.content = content 4 | } 5 | 6 | valueToJs(xml) { 7 | switch (xml.tag) { 8 | case "dict": 9 | return this.dictToJs(xml) 10 | case "true": 11 | case "false": 12 | return xml.tag === "true" 13 | case "integer": 14 | return xml.number 15 | case "key": 16 | case "string": 17 | return xml.string 18 | case "date": 19 | return new Date(xml.string) 20 | case "array": 21 | return this.arrayToJs(xml) 22 | default: 23 | return xml.node 24 | } 25 | } 26 | 27 | arrayToJs(xml) { 28 | const arr = [] 29 | xml.children().forEach(item => { 30 | arr.push(this.valueToJs(item)) 31 | }) 32 | 33 | return arr 34 | } 35 | 36 | dictToJs(xml) { 37 | const keys = [], 38 | values = [] 39 | xml.children().forEach(item => { 40 | if (item.tag === "key") { 41 | keys.push(this.valueToJs(item)) 42 | } else { 43 | values.push(this.valueToJs(item)) 44 | } 45 | }) 46 | 47 | return Object.fromEntries(keys.map((key, i) => [key, values[i]])) 48 | } 49 | 50 | getObject() { 51 | if (!this.content) { 52 | return false 53 | } 54 | const xml = $xml.parse({ string: this.content, mode: "xml" }) 55 | return this.valueToJs( 56 | xml.rootElement.firstChild({ 57 | xPath: "//plist/dict" 58 | }) 59 | ) 60 | } 61 | 62 | static get(content) { 63 | const plist = new this(content) 64 | return plist.getObject() 65 | } 66 | } 67 | 68 | module.exports = { 69 | Plist 70 | } 71 | -------------------------------------------------------------------------------- /src/ui-loading.js: -------------------------------------------------------------------------------- 1 | class UILoading { 2 | #labelId 3 | text = "" 4 | interval 5 | fullScreen = false 6 | #loop = () => {} 7 | 8 | constructor() { 9 | this.#labelId = $text.uuid 10 | } 11 | 12 | updateText(text) { 13 | $(this.#labelId).text = text 14 | } 15 | 16 | setLoop(loop) { 17 | if (typeof loop !== "function") { 18 | throw "loop must be a function" 19 | } 20 | this.#loop = loop 21 | } 22 | 23 | done() { 24 | clearInterval(this.interval) 25 | } 26 | 27 | load() { 28 | $ui.render({ 29 | props: { 30 | navBarHidden: this.fullScreen 31 | }, 32 | views: [ 33 | { 34 | type: "spinner", 35 | props: { 36 | loading: true 37 | }, 38 | layout: (make, view) => { 39 | make.centerY.equalTo(view.super).offset(-15) 40 | make.width.equalTo(view.super) 41 | } 42 | }, 43 | { 44 | type: "label", 45 | props: { 46 | id: this.#labelId, 47 | align: $align.center, 48 | text: "" 49 | }, 50 | layout: (make, view) => { 51 | make.top.equalTo(view.prev.bottom).offset(10) 52 | make.left.right.equalTo(view.super) 53 | } 54 | } 55 | ], 56 | layout: $layout.fill, 57 | events: { 58 | appeared: () => { 59 | this.interval = setInterval(() => { 60 | this.#loop() 61 | }, 100) 62 | } 63 | } 64 | }) 65 | } 66 | } 67 | 68 | module.exports = { 69 | UILoading 70 | } 71 | -------------------------------------------------------------------------------- /demo/scripts/ui/list.js: -------------------------------------------------------------------------------- 1 | const { NavigationView, SearchBar } = require("../libs/easy-jsbox") 2 | 3 | class ListUI { 4 | constructor(kernel) { 5 | this.kernel = kernel 6 | } 7 | 8 | getPage() { 9 | // 初始化搜索条 10 | const searchBar = new SearchBar() 11 | searchBar.controller.setEvent("onChange", text => { 12 | $ui.toast(text) 13 | }) 14 | // 初始化页面控制器 15 | const navigationView = new NavigationView() 16 | // 设置导航条元素 17 | navigationView.navigationBarTitle($l10n("LIST")) 18 | // pinTitleView() 方法将会始终保持 titleView 可见 19 | navigationView.navigationBarItems.setTitleView(searchBar).pinTitleView() 20 | navigationView.navigationBarItems.setLeftButtons([ 21 | { 22 | symbol: "plus.circle", 23 | tapped: animate => { 24 | animate.start() 25 | $ui.alert({ 26 | title: $l10n("HOME_PLUS_BUTTON_MESSAGE"), 27 | actions: [ 28 | { 29 | title: "OK", 30 | handler: () => { 31 | animate.done() 32 | } 33 | }, 34 | { 35 | title: "Cancel", 36 | handler: () => { 37 | animate.cancel() 38 | } 39 | } 40 | ] 41 | }) 42 | } 43 | } 44 | ]) 45 | // 修改导航条背景色 46 | navigationView.navigationBar.setBackgroundColor($color("primarySurface")) 47 | // 添加视图 48 | navigationView.setView({ 49 | type: "list", 50 | props: { 51 | data: ["Hello", "World"] 52 | }, 53 | layout: $layout.fill 54 | }) 55 | return navigationView.getPage() 56 | } 57 | } 58 | 59 | module.exports = ListUI 60 | -------------------------------------------------------------------------------- /demo/setting.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "GENERAL", 4 | "items": [ 5 | { 6 | "icon": ["tag.fill", "#9B59B6"], 7 | "title": "TIPS", 8 | "type": "script", 9 | "key": "tips", 10 | "value": "this.method.tips" 11 | }, 12 | { 13 | "icon": ["doc.on.clipboard", "#FFCC66"], 14 | "type": "child", 15 | "title": "child", 16 | "key": "child", 17 | "children": [ 18 | { 19 | "items": [ 20 | { 21 | "icon": ["text.alignleft", "#FFCC66"], 22 | "title": "number", 23 | "type": "number", 24 | "key": "child.number", 25 | "value": 100 26 | }, 27 | { 28 | "icon": ["square.and.arrow.down.on.square", "#FF6633"], 29 | "title": "switch", 30 | "type": "switch", 31 | "key": "child.switch", 32 | "value": true 33 | } 34 | ] 35 | } 36 | ] 37 | } 38 | ] 39 | }, 40 | { 41 | "title": "ABOUT", 42 | "items": [ 43 | { 44 | "icon": ["icon_177", "black"], 45 | "title": "Github", 46 | "type": "info", 47 | "value": ["EasyJsBox", "https://github.com/ipuppet/EasyJsBox"] 48 | }, 49 | { 50 | "icon": ["icon_172", "#1888bf"], 51 | "title": "Telegram", 52 | "type": "info", 53 | "value": ["JSBoxTG", "https://t.me/JSBoxTG"] 54 | }, 55 | { 56 | "icon": ["person.fill", "#FF9900"], 57 | "title": "AUTHOR", 58 | "type": "info", 59 | "key": "author", 60 | "value": ["ipuppet", "https://blog.ultagic.com"] 61 | }, 62 | { 63 | "icon": ["book.fill", "#A569BD"], 64 | "title": "README", 65 | "type": "script", 66 | "key": "readme", 67 | "value": "this.method.readme" 68 | } 69 | ] 70 | } 71 | ] 72 | -------------------------------------------------------------------------------- /demo/scripts/app.js: -------------------------------------------------------------------------------- 1 | const { Kernel, Setting, Sheet, Toast } = require("./libs/easy-jsbox") 2 | 3 | class AppKernel extends Kernel { 4 | constructor() { 5 | super() 6 | this.query = $context.query 7 | this.setting = new Setting() 8 | this.setting.loadConfig() 9 | this.initSettingMethods() 10 | } 11 | 12 | /** 13 | * 注入设置中的脚本类型方法 14 | */ 15 | initSettingMethods() { 16 | /** 17 | * 脚本类型的动画 18 | * @typedef {object} ScriptAnimate 19 | * @property {Function} animate.start 会出现加载动画 20 | * @property {Function} animate.cancel 会直接恢复箭头图标 21 | * @property {Function} animate.done 会出现对号,然后恢复箭头 22 | * @property {Function} animate.touchHighlightStart 被点击的一行颜色加深 23 | * @property {Function} animate.touchHighlightEnd 被点击的一行颜色恢复 24 | * 25 | * @type {ScriptAnimate} animate 26 | */ 27 | this.setting.method.readme = animate => { 28 | const content = $file.read("/README.md").string 29 | const sheet = new Sheet() 30 | sheet 31 | .setView({ 32 | type: "markdown", 33 | props: { content: content }, 34 | layout: (make, view) => { 35 | make.size.equalTo(view.super) 36 | } 37 | }) 38 | .addNavBar($l10n("README")) 39 | .init() 40 | .present() 41 | } 42 | 43 | this.setting.method.tips = animate => { 44 | Toast.info("Tips.") 45 | } 46 | } 47 | } 48 | 49 | module.exports = { 50 | run: () => { 51 | if ($app.env === $env.widget) { 52 | $widget.setTimeline({ 53 | render: () => { 54 | return { 55 | type: "text", 56 | props: { 57 | text: "Widget" 58 | } 59 | } 60 | } 61 | }) 62 | } else if ($app.env === $env.app) { 63 | const kernel = new AppKernel() 64 | const Factory = require("./ui/factory") 65 | new Factory(kernel).render() 66 | } else { 67 | $intents.finish("不支持在此环境中运行") 68 | $ui.render({ 69 | views: [ 70 | { 71 | type: "label", 72 | props: { 73 | text: "不支持在此环境中运行", 74 | align: $align.center 75 | }, 76 | layout: (make, view) => { 77 | make.center.equalTo(view.super) 78 | make.size.equalTo(view.super) 79 | } 80 | } 81 | ] 82 | }) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tools/matrix.js: -------------------------------------------------------------------------------- 1 | class Matrix { 2 | constructor() { 3 | this.indexFlag = 1 4 | this.height = 90 5 | this.spacing = 15 6 | this.columns = 2 7 | this.id = "Matrix" 8 | this.contentViewId = this.id + "Content" 9 | } 10 | 11 | getWidth() { 12 | this.width = $device.info.screen.width / this.columns 13 | this.width = this.width - this.spacing * (this.columns + 1) / this.columns 14 | return this.width 15 | } 16 | 17 | cardTemplate(views, events) { 18 | return { 19 | type: "view", 20 | props: { 21 | bgcolor: $color("tertiarySurface"), 22 | cornerRadius: 10 23 | }, 24 | layout: (make, view) => { 25 | make.width.equalTo(view.super.width) 26 | .multipliedBy(1 / this.columns) 27 | // this.spacing / this.columns 是应为最后一个块右侧边距为0,需要所有块均摊最后一个块右侧边距 28 | .offset(-this.spacing - this.spacing / this.columns) 29 | make.height.equalTo(this.height) 30 | // 边距控制 31 | if (this.indexFlag === 1) { 32 | make.left.inset(this.spacing) 33 | if (!view.prev) { 34 | make.top.equalTo(view.super).offset(this.spacing) 35 | } else { 36 | make.top.equalTo(view.prev).offset(this.height + this.spacing) 37 | } 38 | } else { 39 | make.left.equalTo(view.prev.right).offset(this.spacing) 40 | make.top.equalTo(view.prev) 41 | } 42 | this.indexFlag === this.columns ? this.indexFlag = 1 : this.indexFlag++ 43 | }, 44 | views: views, 45 | events: events 46 | } 47 | } 48 | 49 | scrollTemplate(data, bottomOffset = this.spacing) { 50 | // 计算尺寸 51 | const line = Math.ceil(data.length / this.columns) 52 | const height = line * (this.height + this.spacing) + bottomOffset 53 | return { 54 | type: "scroll", 55 | props: { 56 | id: this.id, 57 | bgcolor: $color("insetGroupedBackground"), 58 | scrollEnabled: true, 59 | indicatorInsets: $insets(this.spacing, 0, 50, 0), 60 | contentSize: $size(0, height) 61 | }, 62 | layout: (make, view) => { 63 | make.left.right.equalTo(view.super.safeArea) 64 | make.bottom.inset(0) 65 | view.prev ? make.top.equalTo(view.prev).offset(50) : make.top.inset(0) 66 | }, 67 | events: { 68 | layoutSubviews: () => { 69 | const addView = () => { 70 | // 重置变量 71 | this.indexFlag = 1 72 | // 插入视图 73 | if ($(this.contentViewId)) $(this.contentViewId).remove() 74 | $(this.id).add({ 75 | type: "view", 76 | props: { id: this.contentViewId }, 77 | views: data, 78 | layout: (make, view) => { 79 | make.size.equalTo(view.super) 80 | } 81 | }) 82 | } 83 | if (!this.orientation) { 84 | this.orientation = $device.info.screen.orientation 85 | addView() 86 | return 87 | } 88 | if (this.orientation !== $device.info.screen.orientation) { 89 | this.orientation = $device.info.screen.orientation 90 | addView() 91 | } 92 | } 93 | } 94 | } 95 | } 96 | } 97 | 98 | module.exports = { 99 | VERSION: "1.0.0", 100 | Matrix 101 | } -------------------------------------------------------------------------------- /src/l10n.js: -------------------------------------------------------------------------------- 1 | class L10n { 2 | static l10n(language, content, override) { 3 | if (typeof content === "string") { 4 | const strings = {} 5 | const strArr = content.split(";") 6 | strArr.forEach(line => { 7 | line = line.trim() 8 | if (line !== "") { 9 | const kv = line.split("=") 10 | strings[kv[0].trim().slice(1, -1)] = kv[1].trim().slice(1, -1) 11 | } 12 | }) 13 | content = strings 14 | } 15 | const strings = $app.strings 16 | if (override) { 17 | Object.assign(strings[language], content) 18 | } else { 19 | for (const key in content) { 20 | if (!strings[language][key]) { 21 | strings[language][key] = content[key] 22 | } 23 | } 24 | } 25 | $app.strings = strings 26 | } 27 | 28 | static set(language, content) { 29 | this.l10n(language, content, true) 30 | } 31 | 32 | static add(language, content) { 33 | this.l10n(language, content, false) 34 | } 35 | 36 | static init() { 37 | // setting 38 | this.add("zh-Hans", { 39 | OK: "好", 40 | DONE: "完成", 41 | CANCEL: "取消", 42 | CLEAR: "清除", 43 | BACK: "返回", 44 | ERROR: "发生错误", 45 | SUCCESS: "成功", 46 | INVALID_VALUE: "非法参数", 47 | CONFIRM_CHANGES: "数据已变化,确认修改?", 48 | 49 | SETTING: "设置", 50 | GENERAL: "一般", 51 | ADVANCED: "高级", 52 | TIPS: "小贴士", 53 | COLOR: "颜色", 54 | COPY: "复制", 55 | COPIED: "复制成功", 56 | 57 | JSBOX_ICON: "JSBox 内置图标", 58 | SF_SYMBOLS: "SF Symbols", 59 | IMAGE_BASE64: "图片 / base64", 60 | 61 | PREVIEW: "预览", 62 | SELECT_IMAGE_PHOTO: "从相册选择图片", 63 | SELECT_IMAGE_ICLOUD: "从 iCloud 选择图片", 64 | CLEAR_IMAGE: "清除图片", 65 | NO_IMAGE: "无图片", 66 | 67 | ABOUT: "关于", 68 | VERSION: "Version", 69 | AUTHOR: "作者", 70 | AT_BOTTOM: "已经到底啦~" 71 | }) 72 | this.add("en", { 73 | OK: "OK", 74 | DONE: "Done", 75 | CANCEL: "Cancel", 76 | CLEAR: "Clear", 77 | BACK: "Back", 78 | ERROR: "Error", 79 | SUCCESS: "Success", 80 | INVALID_VALUE: "Invalid value", 81 | CONFIRM_CHANGES: "The data has changed, confirm the modification?", 82 | 83 | SETTING: "Setting", 84 | GENERAL: "General", 85 | ADVANCED: "Advanced", 86 | TIPS: "Tips", 87 | COLOR: "Color", 88 | COPY: "Copy", 89 | COPIED: "Copide", 90 | 91 | JSBOX_ICON: "JSBox in app icon", 92 | SF_SYMBOLS: "SF Symbols", 93 | IMAGE_BASE64: "Image / base64", 94 | 95 | PREVIEW: "Preview", 96 | SELECT_IMAGE_PHOTO: "Select From Photo", 97 | SELECT_IMAGE_ICLOUD: "Select From iCloud", 98 | CLEAR_IMAGE: "Clear Image", 99 | NO_IMAGE: "No Image", 100 | 101 | ABOUT: "About", 102 | VERSION: "Version", 103 | AUTHOR: "Author", 104 | AT_BOTTOM: "It's the end~" 105 | }) 106 | 107 | // uikit 108 | this.add("zh-Hans", { DELETE_CONFIRM_TITLE: "删除前确认" }) 109 | this.add("en", { DELETE_CONFIRM_TITLE: "Delete Confirmation" }) 110 | 111 | // file-manager 112 | this.add("zh-Hans", { 113 | FILE_MANAGER_DELETE_CONFIRM_MSG: "确认要删除吗", 114 | DELETE: "删除", 115 | CANCEL: "取消", 116 | CLOSE: "关闭", 117 | SHARE: "分享", 118 | SAVE: "保存", 119 | SAVE_SUCCESS: "保存成功" 120 | }) 121 | this.add("en", { 122 | FILE_MANAGER_DELETE_CONFIRM_MSG: "Are you sure you want to delete", 123 | DELETE: "Delete", 124 | CANCEL: "Cancel", 125 | CLOSE: "Close", 126 | SHARE: "Share", 127 | SAVE: "Save", 128 | SAVE_SUCCESS: "Save Success" 129 | }) 130 | } 131 | } 132 | 133 | module.exports = { 134 | L10n 135 | } 136 | -------------------------------------------------------------------------------- /src/toast.js: -------------------------------------------------------------------------------- 1 | const { UIKit } = require("./ui-kit") 2 | 3 | class Toast { 4 | static type = { 5 | info: undefined, 6 | success: "checkmark", 7 | warning: "exclamationmark.triangle", 8 | error: "xmark.circle" 9 | } 10 | static edges = 40 11 | static iconSize = 100 12 | static labelTopMargin = 10 13 | static defaultFont = $font("default", 26) 14 | 15 | width = Math.min(UIKit.windowSize.width * 0.6, 260) 16 | labelWidth = this.width - Toast.edges * 2 17 | 18 | id = $text.uuid 19 | 20 | #message = "" 21 | font = Toast.defaultFont 22 | type = Toast.type.info 23 | labelLines = 2 24 | 25 | constructor(message, type = Toast.type.info, labelLines = 2, font = Toast.defaultFont) { 26 | // 先确定类型,用于高度计算 27 | this.type = type 28 | this.message = message 29 | this.labelLines = labelLines 30 | this.font = font 31 | } 32 | 33 | get message() { 34 | return this.#message 35 | } 36 | 37 | set message(message) { 38 | this.#message = message 39 | this.fontHeight = UIKit.getContentSize(this.font, this.message, this.labelWidth, this.labelLines).height 40 | this.height = (this.hasIcon ? Toast.labelTopMargin + Toast.iconSize : 0) + this.fontHeight + Toast.edges * 2 41 | } 42 | 43 | get hasIcon() { 44 | return this.type !== undefined 45 | } 46 | 47 | get blurBox() { 48 | const blurBox = UIKit.blurBox({ id: this.id, cornerRadius: 15, alpha: 0 }, [ 49 | { 50 | type: "image", 51 | props: { 52 | symbol: this.type, 53 | hidden: !this.hasIcon, 54 | tintColor: $color("lightGray") 55 | }, 56 | layout: (make, view) => { 57 | make.top.inset(Toast.edges) 58 | make.size.equalTo(Toast.iconSize) 59 | make.centerX.equalTo(view.super) 60 | } 61 | }, 62 | { 63 | type: "label", 64 | props: { 65 | font: this.font, 66 | text: this.message, 67 | align: $align.center, 68 | lines: this.labelLines, 69 | color: $color("lightGray") 70 | }, 71 | layout: (make, view) => { 72 | make.bottom.equalTo(view.supper).offset(-Toast.edges) 73 | make.width.equalTo(this.labelWidth) 74 | make.height.equalTo(this.fontHeight) 75 | make.centerX.equalTo(view.super) 76 | } 77 | } 78 | ]) 79 | blurBox.events = { 80 | tapped: () => { 81 | this.remove() 82 | } 83 | } 84 | 85 | return blurBox 86 | } 87 | 88 | show() { 89 | const blurBox = $ui.create(this.blurBox) 90 | if ($ui.controller.view.hidden) { 91 | $ui.controller.view.super.insertAtIndex(blurBox, 0) 92 | } else { 93 | $ui.controller.view.insertAtIndex(blurBox, 0) 94 | } 95 | const toast = $(this.id) 96 | toast.layout((make, view) => { 97 | make.center.equalTo(view.super) 98 | make.size.equalTo($size(this.width, this.height)) 99 | }) 100 | toast.moveToFront() 101 | $ui.animate({ 102 | duration: 0.2, 103 | animation: () => { 104 | toast.alpha = 1 105 | } 106 | }) 107 | } 108 | 109 | remove() { 110 | const toast = $(this.id) 111 | if (!toast) return 112 | $ui.animate({ 113 | duration: 0.2, 114 | animation: () => { 115 | toast.alpha = 0 116 | }, 117 | completion: () => { 118 | toast.remove() 119 | } 120 | }) 121 | } 122 | 123 | static toast({ 124 | message, 125 | type = Toast.type.info, 126 | show = true, 127 | displayTime = 2, 128 | labelLines = 2, 129 | font = Toast.defaultFont 130 | }) { 131 | const toast = new Toast(message, type, labelLines, font) 132 | if (show) { 133 | toast.show() 134 | $delay(displayTime, () => { 135 | toast.remove() 136 | }) 137 | } 138 | 139 | return toast 140 | } 141 | static info(message, opts = {}) { 142 | return Toast.toast(Object.assign({ message, type: Toast.type.info }, opts)) 143 | } 144 | static success(message, opts = {}) { 145 | return Toast.toast(Object.assign({ message, type: Toast.type.success }, opts)) 146 | } 147 | static warning(message, opts = {}) { 148 | return Toast.toast(Object.assign({ message, type: Toast.type.warning }, opts)) 149 | } 150 | static error(message, opts = {}) { 151 | return Toast.toast(Object.assign({ message, type: Toast.type.error }, opts)) 152 | } 153 | } 154 | 155 | module.exports = { Toast } 156 | -------------------------------------------------------------------------------- /src/request.js: -------------------------------------------------------------------------------- 1 | const { Logger } = require("./logger") 2 | 3 | class RequestError extends Error { 4 | constructor({ message, code, type } = {}) { 5 | super(message) 6 | this.name = "RequestError" 7 | this.code = code 8 | this.type = type 9 | } 10 | } 11 | 12 | class Request { 13 | static method = { 14 | get: "GET", 15 | post: "POST", 16 | put: "PUT", 17 | delete: "DELETE", 18 | patch: "PATCH", 19 | head: "HEAD", 20 | options: "OPTIONS" 21 | } 22 | static errorType = { 23 | http: 0, 24 | network: 1 25 | } 26 | 27 | cacheContainerKey = $addin?.current?.name + ".request.cache" 28 | 29 | #sharedURLCache 30 | #useCache = false 31 | #ignoreCacheExp = false 32 | cacheLife = 1000 * 60 * 60 * 24 * 30 // ms 33 | #isLogRequest = false 34 | timeout = 5 35 | 36 | /** 37 | * @type {Logger} 38 | */ 39 | logger 40 | 41 | /** 42 | * 43 | * @param {Logger} logger 44 | */ 45 | constructor(logger) { 46 | if (logger instanceof Logger) { 47 | this.logger = logger 48 | } 49 | } 50 | 51 | get cache() { 52 | return $cache.get(this.cacheContainerKey) ?? {} 53 | } 54 | 55 | #logRequest(message) { 56 | if (this.#isLogRequest && this.logger instanceof Logger) { 57 | this.logger.info(message) 58 | } 59 | } 60 | 61 | /** 62 | * 记录请求 63 | */ 64 | logRequest() { 65 | this.#isLogRequest = true 66 | return this 67 | } 68 | 69 | disableLogRequest() { 70 | this.#isLogRequest = false 71 | } 72 | 73 | getCacheKey(url) { 74 | return $text.MD5(url) 75 | } 76 | 77 | getCache(key, _default = null) { 78 | const cache = this.cache 79 | return cache[key] ?? _default 80 | } 81 | 82 | setCache(key, data) { 83 | if (!data) { 84 | return 85 | } 86 | const cache = this.cache 87 | cache[key] = data 88 | $cache.set(this.cacheContainerKey, cache) 89 | } 90 | 91 | removeCache(key) { 92 | let cache = this.cache 93 | delete cache[key] 94 | $cache.set(this.cacheContainerKey, cache) 95 | } 96 | 97 | clearCache() { 98 | $cache.remove(this.cacheContainerKey) 99 | } 100 | 101 | clearNSURLCache() { 102 | if (!this.#sharedURLCache) { 103 | this.#sharedURLCache = $objc("NSURLCache").$sharedURLCache() 104 | } 105 | this.#sharedURLCache.$removeAllCachedResponses() 106 | } 107 | 108 | enableCache() { 109 | this.#useCache = true 110 | return this 111 | } 112 | disableCache() { 113 | this.#useCache = false 114 | return this 115 | } 116 | 117 | ignoreCacheExp() { 118 | this.#ignoreCacheExp = true 119 | } 120 | 121 | /** 122 | * 123 | * @param {string} url 124 | * @param {string} method 125 | * @param {object} body 126 | * @param {object} header 127 | * @param {number} cacheLife ms 128 | * @returns 129 | */ 130 | async request(url, method, body = {}, header = {}, cacheLife = this.cacheLife, opts) { 131 | let cacheKey 132 | const useCache = this.#useCache && method === Request.method.get 133 | if (useCache) { 134 | cacheKey = this.getCacheKey(url) 135 | const cache = this.getCache(cacheKey) 136 | if (cache && (this.#ignoreCacheExp || cache.exp > Date.now())) { 137 | this.#logRequest("get data from cache: " + url) 138 | return cache.data 139 | } 140 | } 141 | 142 | this.#logRequest(`sending request [${method}]: ${url}`) 143 | const resp = await $http.request( 144 | Object.assign( 145 | { 146 | header, 147 | url, 148 | method, 149 | body: method === Request.method.get ? null : body, 150 | timeout: this.timeout 151 | }, 152 | opts 153 | ) 154 | ) 155 | 156 | if (resp.error) { 157 | throw new RequestError({ 158 | type: Request.errorType.network, 159 | message: resp.error.localizedDescription, 160 | code: resp.error.code 161 | }) 162 | } else if (resp?.response?.statusCode >= 400) { 163 | let errMsg = resp.data 164 | if (typeof errMsg === "object") { 165 | errMsg = JSON.stringify(errMsg) 166 | } 167 | throw new RequestError({ 168 | type: Request.errorType.http, 169 | message: errMsg, 170 | code: resp.response.statusCode 171 | }) 172 | } 173 | 174 | if (useCache) { 175 | this.setCache(cacheKey, { 176 | exp: Date.now() + cacheLife, 177 | data: resp 178 | }) 179 | } 180 | return resp 181 | } 182 | } 183 | 184 | module.exports = { Request } 185 | -------------------------------------------------------------------------------- /src/file-storage.js: -------------------------------------------------------------------------------- 1 | class FileStorageParameterError extends Error { 2 | constructor(parameter) { 3 | super(`Parameter [${parameter}] is required.`) 4 | this.name = "FileStorageParameterError" 5 | } 6 | } 7 | 8 | class FileStorageFileNotFoundError extends Error { 9 | constructor(filePath) { 10 | super(`File not found: ${filePath}`) 11 | this.name = "FileStorageFileNotFoundError" 12 | } 13 | } 14 | 15 | class FileStorage { 16 | basePath 17 | 18 | constructor({ basePath = "storage" } = {}) { 19 | this.basePath = basePath 20 | this.#createDirectory(this.basePath) 21 | } 22 | 23 | static join(...path) { 24 | const length = path.length 25 | let result = path[0] 26 | if (length < 2) return result 27 | 28 | for (let i = 0; i < length - 1; ++i) { 29 | let p = path[i + 1] 30 | if (p.startsWith("/")) { 31 | p = p.substring(1) 32 | } 33 | result = result.endsWith("/") ? result + p : result + "/" + p 34 | } 35 | 36 | return result 37 | } 38 | 39 | #createDirectory(path) { 40 | if (!$file.isDirectory(path)) { 41 | $file.mkdir(path) 42 | } 43 | } 44 | 45 | filePath(path = "", createPath = true) { 46 | path = FileStorage.join(this.basePath, path) 47 | 48 | let fileName = "" 49 | 50 | if (!path.endsWith("/")) { 51 | const lastSlash = path.lastIndexOf("/") 52 | const lastPoint = path.lastIndexOf(".") 53 | if (lastPoint > lastSlash) { 54 | fileName = path.substring(lastSlash + 1) 55 | path = path.substring(0, lastSlash + 1) 56 | } 57 | } 58 | 59 | if (createPath) { 60 | this.#createDirectory(path) 61 | } 62 | 63 | return path + fileName 64 | } 65 | 66 | exists(path = "") { 67 | path = this.filePath(path, false) 68 | 69 | if ($file.exists(path)) { 70 | return true 71 | } 72 | 73 | return false 74 | } 75 | 76 | write(path = "", data) { 77 | return new Promise((resolve, reject) => { 78 | try { 79 | const success = this.writeSync(path, data) 80 | if (success) { 81 | resolve(success) 82 | } else { 83 | reject(success) 84 | } 85 | } catch (error) { 86 | reject(error) 87 | } 88 | }) 89 | } 90 | 91 | writeSync(path = "", data) { 92 | if (!data) { 93 | throw new FileStorageParameterError("data") 94 | } 95 | return $file.write({ 96 | data: data, 97 | path: this.filePath(path) 98 | }) 99 | } 100 | 101 | read(path = "") { 102 | return new Promise((resolve, reject) => { 103 | try { 104 | const file = this.readSync(path) 105 | if (file) { 106 | resolve(file) 107 | } else { 108 | reject() 109 | } 110 | } catch (error) { 111 | reject(error) 112 | } 113 | }) 114 | } 115 | 116 | readSync(path = "") { 117 | path = this.filePath(path) 118 | if (!$file.exists(path)) { 119 | throw new FileStorageFileNotFoundError(path) 120 | } 121 | if ($file.isDirectory(path)) { 122 | return $file.list(path) 123 | } 124 | return $file.read(path) 125 | } 126 | 127 | readAsJSON(path = "", _default = null) { 128 | try { 129 | const fileString = this.readSync(path)?.string 130 | return JSON.parse(fileString) 131 | } catch (error) { 132 | return _default 133 | } 134 | } 135 | 136 | static readFromRoot(path = "") { 137 | return new Promise((resolve, reject) => { 138 | try { 139 | const file = FileStorage.readFromRootSync(path) 140 | if (file) { 141 | resolve(file) 142 | } else { 143 | reject() 144 | } 145 | } catch (error) { 146 | reject(error) 147 | } 148 | }) 149 | } 150 | 151 | static readFromRootSync(path = "") { 152 | if (!path) { 153 | throw new FileStorageParameterError("path") 154 | } 155 | if (!$file.exists(path)) { 156 | throw new FileStorageFileNotFoundError(path) 157 | } 158 | if ($file.isDirectory(path)) { 159 | return $file.list(path) 160 | } 161 | return $file.read(path) 162 | } 163 | 164 | static readFromRootAsJSON(path = "", _default = null) { 165 | try { 166 | const fileString = FileStorage.readFromRootSync(path)?.string 167 | return JSON.parse(fileString) 168 | } catch (error) { 169 | return _default 170 | } 171 | } 172 | 173 | delete(path = "") { 174 | return $file.delete(this.filePath(path, false)) 175 | } 176 | 177 | copy(from, to) { 178 | from = this.filePath(from) 179 | to = this.filePath(to) 180 | $file.copy({ src: from, dst: to }) 181 | } 182 | 183 | move(from, to) { 184 | from = this.filePath(from) 185 | to = this.filePath(to) 186 | $file.move({ src: from, dst: to }) 187 | } 188 | } 189 | 190 | module.exports = { 191 | FileStorageParameterError, 192 | FileStorageFileNotFoundError, 193 | FileStorage 194 | } 195 | -------------------------------------------------------------------------------- /src/view.js: -------------------------------------------------------------------------------- 1 | const { UIKit } = require("./ui-kit") 2 | 3 | /** 4 | * 视图基类 5 | */ 6 | class View { 7 | /** 8 | * id 9 | * @type {string} 10 | */ 11 | id = $text.uuid 12 | 13 | /** 14 | * 类型 15 | * @type {string} 16 | */ 17 | type 18 | 19 | /** 20 | * 属性 21 | * @type {object} 22 | */ 23 | props 24 | 25 | /** 26 | * 子视图 27 | * @type {Array} 28 | */ 29 | views 30 | 31 | /** 32 | * 事件 33 | * @type {object} 34 | */ 35 | events 36 | 37 | /** 38 | * 布局函数 39 | * @type {Function} 40 | */ 41 | layout 42 | 43 | #scrollable = undefined 44 | #scrollableView = null 45 | 46 | constructor({ type = "view", props = {}, views = [], events = {}, layout = $layout.fill } = {}) { 47 | // 属性 48 | this.type = type 49 | this.props = props 50 | this.views = views 51 | this.events = events 52 | this.layout = layout 53 | 54 | if (this.props.id) { 55 | this.id = this.props.id 56 | } else { 57 | this.props.id = this.id 58 | } 59 | } 60 | 61 | static create(args) { 62 | return new this(args) 63 | } 64 | 65 | static createFromViews(views) { 66 | return new this({ views }) 67 | } 68 | 69 | get scrollableView() { 70 | return this.scrollable ? this.#scrollableView : null 71 | } 72 | 73 | set scrollableView(view) { 74 | this.#scrollableView = view 75 | } 76 | 77 | get scrollable() { 78 | if (this.#scrollable === undefined) { 79 | this.#scrollable = false 80 | 81 | if (UIKit.scrollViewList.indexOf(this.type) > -1) { 82 | this.scrollableView = this 83 | this.#scrollable = true 84 | } else if (this.views.length > 0) { 85 | const check = views => { 86 | if (this.#scrollable) return 87 | 88 | if (views?.length > 0) { 89 | for (let i = 0; i < views.length; i++) { 90 | if (UIKit.scrollViewList.indexOf(views[i].type) > -1) { 91 | if (typeof views[i] !== View) { 92 | views[i] = View.create(views[i]) 93 | } 94 | this.scrollableView = views[i] 95 | this.#scrollable = true 96 | return 97 | } else { 98 | check(views[i].views) 99 | } 100 | } 101 | } 102 | } 103 | check(this.views) 104 | } 105 | } 106 | 107 | return this.#scrollable 108 | } 109 | 110 | /** 111 | * 只读属性 112 | */ 113 | set scrollable(scrollable) { 114 | throw new Error("[scrollable] is readonly prop.") 115 | } 116 | 117 | setProps(props) { 118 | Object.keys(props).forEach(key => this.setProp(key, props[key])) 119 | return this 120 | } 121 | 122 | setProp(key, prop) { 123 | if (key === "id") { 124 | this.id = prop 125 | } 126 | this.props[key] = prop 127 | return this 128 | } 129 | 130 | setViews(views) { 131 | this.views = views 132 | this.#scrollable = undefined // 重置滚动视图检查状态 133 | return this 134 | } 135 | 136 | setEvents(events) { 137 | Object.keys(events).forEach(event => this.setEvent(event, events[event])) 138 | return this 139 | } 140 | 141 | setEvent(event, action) { 142 | this.events[event] = action 143 | return this 144 | } 145 | 146 | /** 147 | * 事件中间件 148 | * 149 | * 调用处理函数 `action`,第一个参数为用户定义的事件处理函数 150 | * 其余参数为 JSBox 传递的参数,如 sender 等 151 | * 152 | * @param {string} event 事件名称 153 | * @param {Function} action 处理事件的函数 154 | * @returns {this} 155 | */ 156 | eventMiddleware(event, action) { 157 | const old = this.events[event] 158 | this.events[event] = (...args) => { 159 | if (typeof old === "function") { 160 | // 调用处理函数 161 | action(old, ...args) 162 | } 163 | } 164 | return this 165 | } 166 | 167 | assignEvent(event, action) { 168 | const old = this.events[event] 169 | this.events[event] = (...args) => { 170 | if (typeof old === "function") { 171 | old(...args) 172 | } 173 | action(...args) 174 | } 175 | return this 176 | } 177 | 178 | setLayout(layout) { 179 | this.layout = layout 180 | return this 181 | } 182 | 183 | getView() { 184 | return this 185 | } 186 | 187 | get definition() { 188 | return this.getView() 189 | } 190 | } 191 | 192 | class PageView extends View { 193 | constructor(args = {}) { 194 | super(args) 195 | this.activeStatus = true 196 | } 197 | 198 | show() { 199 | $(this.props.id).hidden = false 200 | this.activeStatus = true 201 | } 202 | 203 | hide() { 204 | $(this.props.id).hidden = true 205 | this.activeStatus = false 206 | } 207 | 208 | setHorizontalSafeArea(bool) { 209 | this.horizontalSafeArea = bool 210 | return this 211 | } 212 | 213 | #layout(make, view) { 214 | make.top.bottom.equalTo(view.super) 215 | if (this.horizontalSafeArea) { 216 | make.left.right.equalTo(view.super.safeArea) 217 | } else { 218 | make.left.right.equalTo(view.super) 219 | } 220 | } 221 | 222 | getView() { 223 | this.layout = this.#layout 224 | this.props.clipsToBounds = true 225 | this.props.hidden = !this.activeStatus 226 | return super.getView() 227 | } 228 | } 229 | 230 | module.exports = { 231 | View, 232 | PageView 233 | } 234 | -------------------------------------------------------------------------------- /src/kernel.js: -------------------------------------------------------------------------------- 1 | const { VERSION } = require("./version") 2 | const { UIKit } = require("./ui-kit") 3 | const { L10n } = require("./l10n") 4 | 5 | class Kernel { 6 | startTime = Date.now() 7 | // 隐藏 jsbox 默认 nav 栏 8 | isUseJsboxNav = false 9 | title = $addin?.current?.name 10 | 11 | constructor() { 12 | if ($app.isDebugging) { 13 | console.log("You are running EasyJsBox in debug mode.") 14 | $app.idleTimerDisabled = true 15 | } 16 | 17 | L10n.init() 18 | } 19 | 20 | static isObject(object) { 21 | return object != null && typeof object === "object" 22 | } 23 | 24 | static objectEqual(obj1, obj2) { 25 | // 获取对象的属性名 26 | const keys1 = Object.keys(obj1) 27 | const keys2 = Object.keys(obj2) 28 | 29 | // 检查属性名的长度是否相等 30 | if (keys1.length !== keys2.length) { 31 | return false 32 | } 33 | 34 | // 排序是为了优化性能,可以快速发现不匹配的属性 35 | keys1.sort() 36 | keys2.sort() 37 | 38 | // 检查排序后的属性名是否一一对应 39 | for (let i = 0; i < keys1.length; i++) { 40 | if (keys1[i] !== keys2[i]) { 41 | return false 42 | } 43 | } 44 | 45 | // 深度比较每个属性的值 46 | for (let key of keys1) { 47 | const val1 = obj1[key] 48 | const val2 = obj2[key] 49 | 50 | // 检查属性值是否是对象,如果是,则递归比较 51 | const areObjects = Kernel.isObject(val1) && Kernel.isObject(val2) 52 | if ((areObjects && !Kernel.objectEqual(val1, val2)) || (!areObjects && val1 !== val2)) { 53 | return false 54 | } 55 | } 56 | 57 | // 如果所有检查都通过,则两个对象相等 58 | return true 59 | } 60 | 61 | /** 62 | * 对比版本号 63 | * @param {string} preVersion 64 | * @param {string} lastVersion 65 | * @returns {number} 1: preVersion 大, 0: 相等, -1: lastVersion 大 66 | */ 67 | static versionCompare(preVersion = "", lastVersion = "") { 68 | let sources = preVersion.split(".") 69 | let dests = lastVersion.split(".") 70 | let maxL = Math.max(sources.length, dests.length) 71 | let result = 0 72 | for (let i = 0; i < maxL; ++i) { 73 | let preValue = sources.length > i ? sources[i] : 0 74 | let preNum = isNaN(Number(preValue)) ? preValue.charCodeAt() : Number(preValue) 75 | let lastValue = dests.length > i ? dests[i] : 0 76 | let lastNum = isNaN(Number(lastValue)) ? lastValue.charCodeAt() : Number(lastValue) 77 | if (preNum < lastNum) { 78 | result = -1 79 | break 80 | } else if (preNum > lastNum) { 81 | result = 1 82 | break 83 | } 84 | } 85 | return result 86 | } 87 | 88 | useJsboxNav() { 89 | this.isUseJsboxNav = true 90 | return this 91 | } 92 | 93 | setTitle(title) { 94 | if (this.isUseJsboxNav) { 95 | $ui.title = title 96 | } 97 | this.title = title 98 | } 99 | 100 | setNavButtons(buttons) { 101 | this.navButtons = buttons 102 | } 103 | 104 | /** 105 | * 在 JSBox 主程序打开自己,用于键盘等其他环境 106 | */ 107 | openInJsbox() { 108 | $app.openURL(`jsbox://run?name=${this.title}`) 109 | } 110 | 111 | UIRender(view = {}) { 112 | const query = $context.query 113 | if (query.type === "alertFromKeyboard") { 114 | const object = JSON.parse($text.URLDecode(query.value)) 115 | object.actions = [{ title: $l10n("CANCEL") }] 116 | $ui.alert(object) 117 | return 118 | } 119 | try { 120 | view.props = Object.assign( 121 | { 122 | title: this.title, 123 | navBarHidden: !this.isUseJsboxNav, 124 | navButtons: this.navButtons ?? [], 125 | statusBarStyle: 0 126 | }, 127 | view.props 128 | ) 129 | if (!view.events) { 130 | view.events = {} 131 | } 132 | const oldLayoutSubviews = view.events.layoutSubviews 133 | view.events.layoutSubviews = () => { 134 | $app.notify({ 135 | name: "interfaceOrientationEvent", 136 | object: { 137 | statusBarOrientation: UIKit.statusBarOrientation, 138 | isHorizontal: UIKit.isHorizontal 139 | } 140 | }) 141 | if (typeof oldLayoutSubviews === "function") oldLayoutSubviews() 142 | } 143 | $ui.render(view) 144 | } catch (error) { 145 | this.print(error) 146 | } 147 | } 148 | 149 | KeyboardRender(view = {}, height = 267) { 150 | if (!view.id) view.id = $text.uuid 151 | 152 | $ui.render({ 153 | events: { 154 | appeared: () => { 155 | $keyboard.height = height 156 | $ui.controller.view = $ui.create(view) 157 | $ui.controller.view.layout(view.layout) 158 | } 159 | } 160 | }) 161 | } 162 | KeyboardRenderWithViewFunc(getView, height = 267) { 163 | $ui.render({ 164 | events: { 165 | appeared: async () => { 166 | $keyboard.height = height 167 | const view = await getView() 168 | if (!view.id) view.id = $text.uuid 169 | $ui.controller.view = $ui.create(view) 170 | $ui.controller.view.layout(view.layout) 171 | } 172 | } 173 | }) 174 | } 175 | 176 | async checkUpdate() { 177 | const branche = "dev" // 更新版本,可选 master, dev 178 | const configRes = await $http.get( 179 | `https://raw.githubusercontent.com/ipuppet/EasyJsBox/${branche}/src/version.js` 180 | ) 181 | if (configRes.error) { 182 | throw configRes.error 183 | } 184 | 185 | const latestVersion = configRes.data.match(/.*VERSION.+\"([0-9\.]+)\"/)[1] 186 | 187 | this.print(`easy-jsbox latest version: ${latestVersion}`) 188 | if (Kernel.versionCompare(latestVersion, VERSION) > 0) { 189 | const srcRes = await $http.get( 190 | `https://raw.githubusercontent.com/ipuppet/EasyJsBox/${branche}/dist/easy-jsbox.js` 191 | ) 192 | if (srcRes.error) { 193 | throw srcRes.error 194 | } 195 | 196 | return srcRes.data 197 | } 198 | 199 | return false 200 | } 201 | } 202 | 203 | module.exports = { 204 | Kernel 205 | } 206 | -------------------------------------------------------------------------------- /src/sheet.js: -------------------------------------------------------------------------------- 1 | const { ValidationError } = require("./validation-error") 2 | const { NavigationView } = require("./navigation-view/navigation-view") 3 | const { NavigationBar } = require("./navigation-view/navigation-bar") 4 | const { UIKit } = require("./ui-kit") 5 | 6 | class SheetViewUndefinedError extends Error { 7 | constructor() { 8 | super("Please call setView(view) first.") 9 | this.name = "SheetViewUndefinedError" 10 | } 11 | } 12 | 13 | class SheetViewTypeError extends ValidationError { 14 | constructor(parameter, type) { 15 | super(parameter, type) 16 | this.name = "SheetViewTypeError" 17 | } 18 | } 19 | 20 | class Sheet { 21 | #present = () => {} 22 | #dismiss = () => {} 23 | style = Sheet.UIModalPresentationStyle.PageSheet 24 | #preventDismiss = false 25 | #navBar 26 | #willDismiss 27 | #didDismiss 28 | 29 | static UIModalPresentationStyle = { 30 | Automatic: -2, 31 | FullScreen: 0, 32 | PageSheet: 1, 33 | FormSheet: 2, 34 | CurrentContext: 3, 35 | Custom: 4, 36 | OverFullScreen: 5, 37 | OverCurrentContext: 6, 38 | Popover: 7, 39 | BlurOverFullScreen: 8 40 | } 41 | 42 | /** 43 | * @type {NavigationView} 44 | */ 45 | navigationView 46 | 47 | init() { 48 | this.initNavBar() 49 | $define({ 50 | type: "SheetViewController: UIViewController", 51 | events: { 52 | "viewWillDisappear:": animated => { 53 | if (typeof this.#willDismiss === "function") { 54 | this.#willDismiss(animated) 55 | } 56 | }, 57 | "viewDidDisappear:": animated => { 58 | if (typeof this.#didDismiss === "function") { 59 | this.#didDismiss(animated) 60 | } 61 | } 62 | } 63 | }) 64 | this.sheetVC = $objc("SheetViewController").$new() 65 | 66 | const view = this.sheetVC.$view() 67 | view.$addSubview($ui.create({ type: "view" })) 68 | this.sheetVC.$setModalPresentationStyle(this.style) 69 | this.sheetVC.$setModalInPresentation(this.#preventDismiss) 70 | this.#present = () => { 71 | view.jsValue().add(this.navigationView?.getPage().definition ?? this.view) 72 | $ui.vc.ocValue().invoke("presentViewController:animated:completion:", this.sheetVC, true, null) 73 | } 74 | this.#dismiss = () => this.sheetVC.invoke("dismissViewControllerAnimated:completion:", true, null) 75 | return this 76 | } 77 | 78 | initNavBar() { 79 | if (!this.#navBar) { 80 | return 81 | } 82 | const { title = "", popButton = { title: $l10n("CLOSE") }, rightButtons = [] } = this.#navBar 83 | if (this.view === undefined) throw new SheetViewUndefinedError() 84 | 85 | this.navigationView = new NavigationView() 86 | const navBar = this.navigationView.navigationBar 87 | navBar.setLargeTitleDisplayMode(NavigationBar.largeTitleDisplayModeNever) 88 | navBar.navigationBarLargeTitleHeight -= navBar.navigationBarNormalHeight 89 | navBar.navigationBarNormalHeight = UIKit.PageSheetNavigationBarNormalHeight 90 | navBar.navigationBarLargeTitleHeight += navBar.navigationBarNormalHeight 91 | if ( 92 | this.style === Sheet.UIModalPresentationStyle.FullScreen || 93 | this.style === Sheet.UIModalPresentationStyle.OverFullScreen || 94 | this.style === Sheet.UIModalPresentationStyle.BlurOverFullScreen 95 | ) { 96 | navBar.setTopSafeArea() 97 | } else { 98 | navBar.removeTopSafeArea() 99 | } 100 | 101 | // 返回按钮 102 | popButton.events = Object.assign( 103 | { 104 | tapped: async () => { 105 | if (typeof popButton.tapped === "function") { 106 | await popButton.tapped() 107 | } 108 | this.dismiss() 109 | } 110 | }, 111 | popButton.events ?? {} 112 | ) 113 | this.navigationView.navigationBarItems.addLeftButton(popButton).setRightButtons(rightButtons) 114 | this.navigationView.setView(this.view).navigationBarTitle(title) 115 | if (this.view.props?.bgcolor) { 116 | this.navigationView?.getPage().setProp("bgcolor", this.view.props?.bgcolor) 117 | } 118 | } 119 | 120 | preventDismiss() { 121 | this.#preventDismiss = true 122 | return this 123 | } 124 | 125 | setStyle(style) { 126 | this.style = style 127 | return this 128 | } 129 | 130 | /** 131 | * 设置 view 132 | * @param {object} view 视图对象 133 | * @returns {this} 134 | */ 135 | setView(view = {}) { 136 | if (typeof view !== "object") throw new SheetViewTypeError("view", "object") 137 | this.view = view 138 | return this 139 | } 140 | 141 | /** 142 | * 为 view 添加一个 navBar 143 | * @param {object} param 144 | * { 145 | * {string} title 146 | * {object} popButton 参数与 BarButtonItem 一致 147 | * {Array} rightButtons 148 | * } 149 | * @returns {this} 150 | */ 151 | addNavBar(navBarOptions) { 152 | this.#navBar = navBarOptions 153 | return this 154 | } 155 | 156 | /** 157 | * 弹出 Sheet 158 | */ 159 | present() { 160 | this.#present() 161 | } 162 | 163 | /** 164 | * 关闭 Sheet 165 | */ 166 | dismiss() { 167 | this.#dismiss() 168 | } 169 | 170 | willDismiss(willDismiss) { 171 | this.#willDismiss = willDismiss 172 | return this 173 | } 174 | didDismiss(didDismiss) { 175 | this.#didDismiss = didDismiss 176 | return this 177 | } 178 | 179 | static quickLookImage(data, title = $l10n("PREVIEW")) { 180 | const sheet = new Sheet() 181 | sheet 182 | .setView({ 183 | type: "view", 184 | views: [ 185 | { 186 | type: "scroll", 187 | props: { 188 | zoomEnabled: true, 189 | maxZoomScale: 3 190 | }, 191 | layout: $layout.fill, 192 | views: [ 193 | { 194 | type: "image", 195 | props: { data: data }, 196 | layout: $layout.fill 197 | } 198 | ] 199 | } 200 | ], 201 | layout: $layout.fill 202 | }) 203 | .addNavBar({ 204 | title, 205 | rightButtons: [ 206 | { 207 | symbol: "square.and.arrow.up", 208 | tapped: () => $share.sheet(data) 209 | } 210 | ] 211 | }) 212 | .init() 213 | .present() 214 | } 215 | } 216 | 217 | module.exports = { 218 | Sheet 219 | } 220 | -------------------------------------------------------------------------------- /src/navigation-view/search-bar.js: -------------------------------------------------------------------------------- 1 | const { UIKit } = require("../ui-kit") 2 | const { Controller } = require("../controller") 3 | 4 | const { BarTitleView } = require("./navigation-bar-items") 5 | 6 | class SearchBar extends BarTitleView { 7 | height = 35 8 | topOffset = 15 9 | bottomOffset = 10 10 | horizontalOffset = 15 11 | kbType = $kbType.search 12 | placeholder = $l10n("SEARCH") 13 | inputEvents = {} 14 | keyboardView 15 | accessoryView 16 | 17 | cancelButtonFont = $font(16) 18 | 19 | constructor(args) { 20 | super(args) 21 | 22 | this.setController(new SearchBarController()) 23 | this.controller.setSearchBar(this) 24 | } 25 | 26 | get cancelButtonWidth() { 27 | return UIKit.getContentSize(this.cancelButtonFont, $l10n("CANCEL")).width 28 | } 29 | 30 | /** 31 | * 重定向 event 到 input 组件 32 | * @param {*} event 33 | * @param {*} action 34 | * @returns 35 | */ 36 | setEvent(event, action) { 37 | this.inputEvents[event] = action 38 | return this 39 | } 40 | 41 | setPlaceholder(placeholder) { 42 | this.placeholder = placeholder 43 | return this 44 | } 45 | 46 | setKbType(kbType) { 47 | this.kbType = kbType 48 | return this 49 | } 50 | 51 | setKeyboardView(keyboardView) { 52 | this.keyboardView = keyboardView 53 | return this 54 | } 55 | 56 | setAccessoryView(accessoryView) { 57 | this.accessoryView = accessoryView 58 | return this 59 | } 60 | 61 | onBeginEditingAnimate() { 62 | $ui.animate({ 63 | duration: 0.3, 64 | animation: () => { 65 | const cancelButtonWidth = this.cancelButtonWidth 66 | $(this.id + "-cancel-button").updateLayout((make, view) => { 67 | make.left.equalTo(view.super.right).offset(-cancelButtonWidth) 68 | }) 69 | $(this.id + "-cancel-button").alpha = 1 70 | $(this.id + "-cancel-button").relayout() 71 | $(this.id + "-input").updateLayout(make => { 72 | make.right.inset(cancelButtonWidth + this.horizontalOffset / 2) 73 | }) 74 | $(this.id + "-input").relayout() 75 | } 76 | }) 77 | } 78 | 79 | onEndEditingAnimate() { 80 | $ui.animate({ 81 | duration: 0.3, 82 | animation: () => { 83 | $(this.id + "-cancel-button").updateLayout((make, view) => { 84 | make.left.equalTo(view.super.right) 85 | }) 86 | $(this.id + "-cancel-button").alpha = 0 87 | $(this.id + "-cancel-button").relayout() 88 | $(this.id + "-input").updateLayout($layout.fill) 89 | $(this.id + "-input").relayout() 90 | } 91 | }) 92 | } 93 | 94 | cancel() { 95 | $(this.id + "-input").blur() 96 | $(this.id + "-input").text = "" 97 | this.onEndEditingAnimate() 98 | this.controller.callEvent("onCancel") 99 | } 100 | 101 | getView() { 102 | this.props = { 103 | id: this.id, 104 | smoothCorners: true, 105 | cornerRadius: 6 106 | } 107 | this.views = [ 108 | { 109 | type: "input", 110 | props: { 111 | id: this.id + "-input", 112 | type: this.kbType, 113 | bgcolor: $color("#EEF1F1", "#212121"), 114 | placeholder: this.placeholder, 115 | keyboardView: this.keyboardView, 116 | accessoryView: this.accessoryView 117 | }, 118 | layout: $layout.fill, 119 | events: Object.assign( 120 | { 121 | didBeginEditing: sender => { 122 | this.onBeginEditingAnimate() 123 | this.controller.callEvent("onBeginEditing", sender.text) 124 | }, 125 | didEndEditing: sender => { 126 | this.controller.callEvent("onEndEditing", sender.text) 127 | }, 128 | changed: sender => this.controller.callEvent("onChange", sender.text), 129 | returned: sender => this.controller.callEvent("onReturn", sender.text) 130 | }, 131 | this.inputEvents 132 | ) 133 | }, 134 | { 135 | type: "button", 136 | props: { 137 | id: this.id + "-cancel-button", 138 | title: $l10n("CANCEL"), 139 | font: this.cancelButtonFont, 140 | titleColor: $color("tintColor"), 141 | bgcolor: $color("clear"), 142 | alpha: 0, 143 | hidden: false 144 | }, 145 | events: { 146 | tapped: () => this.cancel() 147 | }, 148 | layout: (make, view) => { 149 | make.height.equalTo(view.super) 150 | make.width.equalTo(this.cancelButtonWidth) 151 | make.left.equalTo(view.super.right) 152 | } 153 | } 154 | ] 155 | this.layout = (make, view) => { 156 | make.height.equalTo(this.height) 157 | make.top.equalTo(view.super.safeArea).offset(this.topOffset) 158 | make.left.equalTo(view.super.safeArea).offset(this.horizontalOffset) 159 | make.right.equalTo(view.super.safeArea).offset(-this.horizontalOffset) 160 | } 161 | 162 | return this 163 | } 164 | } 165 | 166 | class SearchBarController extends Controller { 167 | setSearchBar(searchBar) { 168 | this.searchBar = searchBar 169 | return this 170 | } 171 | 172 | updateSelector() { 173 | this.selector = { 174 | inputBox: $(this.searchBar.id), 175 | input: $(this.searchBar.id + "-input") 176 | } 177 | } 178 | 179 | hide() { 180 | this.updateSelector() 181 | this.selector.inputBox.updateLayout(make => { 182 | make.height.equalTo(0) 183 | }) 184 | } 185 | 186 | show() { 187 | this.updateSelector() 188 | this.selector.inputBox.updateLayout(make => { 189 | make.height.equalTo(this.searchBar.height) 190 | }) 191 | } 192 | 193 | didScroll(contentOffset) { 194 | this.updateSelector() 195 | 196 | // 调整大小 197 | let height = this.searchBar.height - contentOffset 198 | height = height > 0 ? (height > this.searchBar.height ? this.searchBar.height : height) : 0 199 | this.selector.inputBox.updateLayout(make => { 200 | make.height.equalTo(height) 201 | }) 202 | // 隐藏内容 203 | if (contentOffset > 0) { 204 | const alpha = (this.searchBar.height / 2 - 5 - contentOffset) / 10 205 | this.selector.input.alpha = alpha 206 | } else { 207 | this.selector.input.alpha = 1 208 | } 209 | } 210 | 211 | didEndDragging(contentOffset, decelerate, scrollToOffset) { 212 | this.updateSelector() 213 | 214 | if (contentOffset >= 0 && contentOffset <= this.searchBar.height) { 215 | scrollToOffset($point(0, contentOffset >= this.searchBar.height / 2 ? this.searchBar.height : 0)) 216 | } 217 | } 218 | } 219 | 220 | module.exports = { 221 | SearchBar, 222 | SearchBarController 223 | } 224 | -------------------------------------------------------------------------------- /tools/sloarToLunar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 来源:https://github.com/xm2by/fragment/blob/master/%E5%85%AC%E5%8E%86%E8%BD%AC%E5%86%9C%E5%8E%86/sloarToLunar.js 3 | */ 4 | 5 | /* 公历转农历代码思路: 6 | 1、建立农历年份查询表 7 | 2、计算输入公历日期与公历基准的相差天数 8 | 3、从农历基准开始遍历农历查询表,计算自农历基准之后每一年的天数,并用相差天数依次相减,确定农历年份 9 | 4、利用剩余相差天数以及农历每个月的天数确定农历月份 10 | 5、利用剩余相差天数确定农历哪一天 */ 11 | 12 | // 农历1949-2100年查询表 13 | const lunarYearArr = [ 14 | 0x0b557, //1949 15 | 0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, //1950-1959 16 | 0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, //1960-1969 17 | 0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, //1970-1979 18 | 0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, //1980-1989 19 | 0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x055c0, 0x0ab60, 0x096d5, 0x092e0, //1990-1999 20 | 0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, //2000-2009 21 | 0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, //2010-2019 22 | 0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, //2020-2029 23 | 0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, //2030-2039 24 | 0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, //2040-2049 25 | 0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0, //2050-2059 26 | 0x0a2e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, //2060-2069 27 | 0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, //2070-2079 28 | 0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, //2080-2089 29 | 0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252, //2090-2099 30 | 0x0d520 //2100 31 | ], 32 | lunarMonth = ['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊'], 33 | lunarDay = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '初', '廿'], 34 | tianGan = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'], 35 | diZhi = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'] 36 | 37 | class SloarToLunar { 38 | /** 39 | * 公历转农历 40 | * @param {Number} sy 年 41 | * @param {Number} sm 月 42 | * @param {Number} sd 日 43 | * @returns 44 | */ 45 | sloarToLunar(sy, sm, sd) { 46 | // 输入的月份减1处理 47 | sm -= 1 48 | // 计算与公历基准的相差天数 49 | // Date.UTC()返回的是距离公历1970年1月1日的毫秒数,传入的月份需要减1 50 | let daySpan = (Date.UTC(sy, sm, sd) - Date.UTC(1949, 0, 29)) / (24 * 60 * 60 * 1000) + 1 51 | let ly, lm, ld 52 | // 确定输出的农历年份 53 | for (let j = 0; j < lunarYearArr.length; j++) { 54 | daySpan -= this.lunarYearDays(lunarYearArr[j]) 55 | if (daySpan <= 0) { 56 | ly = 1949 + j 57 | // 获取农历年份确定后的剩余天数 58 | daySpan += this.lunarYearDays(lunarYearArr[j]) 59 | break 60 | } 61 | } 62 | // 确定输出的农历月份 63 | for (let k = 0; k < this.lunarYearMonths(lunarYearArr[ly - 1949]).length; k++) { 64 | daySpan -= this.lunarYearMonths(lunarYearArr[ly - 1949])[k] 65 | if (daySpan <= 0) { 66 | // 有闰月时,月份的数组长度会变成13,因此,当闰月月份小于等于k时,lm不需要加1 67 | if (this.hasLeapMonth(lunarYearArr[ly - 1949]) && this.hasLeapMonth(lunarYearArr[ly - 1949]) <= k) { 68 | if (this.hasLeapMonth(lunarYearArr[ly - 1949]) < k) { 69 | lm = k 70 | } else if (this.hasLeapMonth(lunarYearArr[ly - 1949]) === k) { 71 | lm = '闰' + k 72 | } else { 73 | lm = k + 1 74 | } 75 | } else { 76 | lm = k + 1 77 | } 78 | // 获取农历月份确定后的剩余天数 79 | daySpan += this.lunarYearMonths(lunarYearArr[ly - 1949])[k] 80 | break 81 | } 82 | } 83 | // 确定输出农历哪一天 84 | ld = daySpan 85 | // 将计算出来的农历月份转换成汉字月份,闰月需要在前面加上闰字 86 | if (this.hasLeapMonth(lunarYearArr[ly - 1949]) && (typeof (lm) === 'string' && lm.indexOf('闰') > -1)) { 87 | lm = `闰${lunarMonth[/\d/.exec(lm) - 1]}` 88 | } else { 89 | lm = lunarMonth[lm - 1] 90 | } 91 | // 将计算出来的农历年份转换为天干地支年 92 | ly = this.getTianGan(ly) + this.getDiZhi(ly) 93 | // 将计算出来的农历天数转换成汉字 94 | if (ld < 11) { 95 | ld = `${lunarDay[10]}${lunarDay[ld - 1]}` 96 | } else if (ld > 10 && ld < 20) { 97 | ld = `${lunarDay[9]}${lunarDay[ld - 11]}` 98 | } else if (ld === 20) { 99 | ld = `${lunarDay[1]}${lunarDay[9]}` 100 | } else if (ld > 20 && ld < 30) { 101 | ld = `${lunarDay[11]}${lunarDay[ld - 21]}` 102 | } else if (ld === 30) { 103 | ld = `${lunarDay[2]}${lunarDay[9]}` 104 | } 105 | // console.log(ly, lm, ld) 106 | return { 107 | lunarYear: ly, 108 | lunarMonth: lm, 109 | lunarDay: ld, 110 | } 111 | } 112 | 113 | /** 114 | * 计算农历年是否有闰月 115 | * 农历年份信息用16进制存储,其中16进制的最后1位可以用于判断是否有闰月 116 | * @param {Number} ly 存储农历年的16进制 117 | * @returns 118 | */ 119 | hasLeapMonth(ly) { 120 | // 获取16进制的最后1位,需要用到&与运算符 121 | if (ly & 0xf) { 122 | return ly & 0xf 123 | } else { 124 | return false 125 | } 126 | } 127 | 128 | /** 129 | * 如果有闰月,计算农历闰月天数 130 | * 农历年份信息用16进制存储,其中16进制的第1位(0x除外)可以用于表示闰月是大月还是小月 131 | * @param {Number} ly 存储农历年的16进制 132 | * @returns 133 | */ 134 | leapMonthDays(ly) { 135 | if (this.hasLeapMonth(ly)) { 136 | // 获取16进制的第1位(0x除外) 137 | return (ly & 0xf0000) ? 30 : 29 138 | } else { 139 | return 0 140 | } 141 | } 142 | 143 | /** 144 | * 计算农历一年的总天数 145 | * 农历年份信息用16进制存储,其中16进制的第2-4位(0x除外)可以用于表示正常月是大月还是小月 146 | * @param {Number} ly 存储农历年的16进制 147 | * @returns 148 | */ 149 | lunarYearDays(ly) { 150 | let totalDays = 0 151 | // 获取正常月的天数,并累加 152 | // 获取16进制的第2-4位,需要用到>>移位运算符 153 | for (let i = 0x8000; i > 0x8; i >>= 1) { 154 | let monthDays = (ly & i) ? 30 : 29 155 | totalDays += monthDays 156 | } 157 | // 如果有闰月,需要把闰月的天数加上 158 | if (this.hasLeapMonth(ly)) { 159 | totalDays += this.leapMonthDays(ly) 160 | } 161 | return totalDays 162 | } 163 | 164 | /** 165 | * 获取农历每个月的天数 166 | * @param {Number} ly 16进制数值 167 | * @returns 168 | */ 169 | lunarYearMonths(ly) { 170 | let monthArr = [] 171 | // 获取正常月的天数,并添加到monthArr数组中 172 | // 获取16进制的第2-4位,需要用到>>移位运算符 173 | for (let i = 0x8000; i > 0x8; i >>= 1) { 174 | monthArr.push((ly & i) ? 30 : 29) 175 | } 176 | // 如果有闰月,需要把闰月的天数加上 177 | if (this.hasLeapMonth(ly)) { 178 | monthArr.splice(this.hasLeapMonth(ly), 0, this.leapMonthDays(ly)) 179 | } 180 | return monthArr 181 | } 182 | 183 | /** 184 | * 将农历年转换为天干,参数为农历年 185 | * @param {Number} ly 186 | * @returns 187 | */ 188 | getTianGan(ly) { 189 | let tianGanKey = (ly - 3) % 10 190 | if (tianGanKey === 0) tianGanKey = 10 191 | return tianGan[tianGanKey - 1] 192 | } 193 | 194 | /** 195 | * 将农历年转换为地支,参数为农历年 196 | * @param {Number} ly 197 | * @returns 198 | */ 199 | getDiZhi(ly) { 200 | let diZhiKey = (ly - 3) % 12 201 | if (diZhiKey === 0) diZhiKey = 12 202 | return diZhi[diZhiKey - 1] 203 | } 204 | } 205 | 206 | module.exports = { 207 | VERSION: "1.0.0", 208 | SloarToLunar 209 | } -------------------------------------------------------------------------------- /src/matrix.js: -------------------------------------------------------------------------------- 1 | const { View } = require("./view") 2 | 3 | class Matrix extends View { 4 | titleStyle = { 5 | font: $font("bold", 21), 6 | height: 30 7 | } 8 | #hiddenViews 9 | #templateHiddenStatus 10 | 11 | templateIdByIndex(i) { 12 | if (this.props.template.views[i]?.props?.id === undefined) { 13 | if (this.props.template.views[i].props === undefined) { 14 | this.props.template.views[i].props = {} 15 | } 16 | this.props.template.views[i].props.id = $text.uuid 17 | } 18 | 19 | return this.props.template.views[i].props.id 20 | } 21 | 22 | get templateHiddenStatus() { 23 | if (!this.#templateHiddenStatus) { 24 | this.#templateHiddenStatus = {} 25 | for (let i = 0; i < this.props.template.views.length; i++) { 26 | // 未定义 id 以及 hidden 的模板默认 hidden 设置为 false 27 | if ( 28 | this.props.template.views[i].props.id === undefined && 29 | this.props.template.views[i].props.hidden === undefined 30 | ) { 31 | this.#templateHiddenStatus[this.templateIdByIndex(i)] = false 32 | } 33 | // 模板中声明 hidden 的值,在数据中将会成为默认值 34 | if (this.props.template.views[i].props.hidden !== undefined) { 35 | this.#templateHiddenStatus[this.templateIdByIndex(i)] = this.props.template.views[i].props.hidden 36 | } 37 | } 38 | } 39 | 40 | return this.#templateHiddenStatus 41 | } 42 | 43 | get hiddenViews() { 44 | if (!this.#hiddenViews) { 45 | this.#hiddenViews = {} 46 | // hide other views 47 | for (let i = 0; i < this.props.template.views.length; i++) { 48 | this.#hiddenViews[this.templateIdByIndex(i)] = { 49 | hidden: true 50 | } 51 | } 52 | } 53 | 54 | return this.#hiddenViews 55 | } 56 | 57 | get data() { 58 | const rawData = $(this.id).data 59 | const data = [] 60 | rawData.map(section => { 61 | section.items = section.items.filter(item => { 62 | return item?.__title?.hidden === true ?? true 63 | }) 64 | data.push(section) 65 | }) 66 | return data 67 | } 68 | set data(data) { 69 | this.props.data = this.rebuildData(data) 70 | $(this.id).data = this.props.data 71 | } 72 | 73 | #titleToData(title) { 74 | let hiddenViews = { ...this.hiddenViews } 75 | 76 | // templateProps & title 77 | Object.assign(hiddenViews, { 78 | __templateProps: { 79 | hidden: true 80 | }, 81 | __title: { 82 | hidden: false, 83 | text: title, 84 | info: { title: true } 85 | } 86 | }) 87 | 88 | return hiddenViews 89 | } 90 | 91 | rebuildData(data = []) { 92 | // rebuild data 93 | return data.map(section => { 94 | section.items = section.items.map(item => { 95 | // 所有元素都重置 hidden 属性 96 | Object.keys(item).forEach(key => { 97 | item[key].hidden = this.templateHiddenStatus[key] ?? false 98 | }) 99 | 100 | // 修正数据 101 | Object.keys(this.templateHiddenStatus).forEach(key => { 102 | if (!item[key]) { 103 | item[key] = {} 104 | } 105 | item[key].hidden = this.templateHiddenStatus[key] 106 | }) 107 | 108 | item.__templateProps = { 109 | hidden: false 110 | } 111 | item.__title = { 112 | hidden: true 113 | } 114 | 115 | return item 116 | }) 117 | 118 | if (section.title) { 119 | section.items.unshift(this.#titleToData(section.title)) 120 | } 121 | 122 | return section 123 | }) 124 | } 125 | 126 | rebuildTemplate() { 127 | let templateProps = {} 128 | if (this.props.template.props !== undefined) { 129 | if (this.props.template.props.cornerRadius !== undefined) { 130 | // 不能裁切子视图,否则标题会被裁切 131 | this.props.template.props.clipsToBounds = false 132 | } 133 | templateProps = Object.assign(this.props.template.props, { 134 | id: "__templateProps", 135 | hidden: false 136 | }) 137 | } 138 | 139 | // rebuild template 140 | const templateViews = [ 141 | { 142 | // templateProps 143 | type: "view", 144 | props: templateProps, 145 | layout: $layout.fill 146 | }, 147 | { 148 | // title 149 | type: "label", 150 | props: { 151 | id: "__title", 152 | hidden: true, 153 | font: this.titleStyle.font 154 | }, 155 | layout: (make, view) => { 156 | make.top.inset(-(this.titleStyle.height / 4) * 3) 157 | make.height.equalTo(this.titleStyle.height) 158 | make.width.equalTo(view.super.safeArea) 159 | } 160 | } 161 | ].concat(this.props.template.views) 162 | this.props.template.views = templateViews 163 | } 164 | 165 | insert(data, withTitleOffset = true) { 166 | data.indexPath = this.indexPath(data.indexPath, withTitleOffset) 167 | return $(this.id).insert(data) 168 | } 169 | 170 | delete(indexPath, withTitleOffset = true) { 171 | indexPath = this.indexPath(indexPath, withTitleOffset) 172 | return $(this.id).delete(indexPath) 173 | } 174 | 175 | object(indexPath, withTitleOffset = true) { 176 | indexPath = this.indexPath(indexPath, withTitleOffset) 177 | return $(this.id).object(indexPath) 178 | } 179 | 180 | cell(indexPath, withTitleOffset = true) { 181 | indexPath = this.indexPath(indexPath, withTitleOffset) 182 | return $(this.id).cell(indexPath) 183 | } 184 | 185 | /** 186 | * 获得修正后的 indexPath 187 | * @param {$indexPath||number} indexPath 188 | * @param {boolean} withTitleOffset 输入的 indexPath 是否已经包含了标题列。通常自身事件返回的 indexPath 视为已包含,使用默认值即可。 189 | * @returns {$indexPath} 190 | */ 191 | indexPath(indexPath, withTitleOffset) { 192 | let offset = withTitleOffset ? 1 : 0 193 | if (typeof indexPath === "number") { 194 | indexPath = $indexPath(0, indexPath + offset) 195 | } else { 196 | indexPath = $indexPath(indexPath.section, indexPath.row + offset) 197 | } 198 | return indexPath 199 | } 200 | 201 | getView() { 202 | // rebuild data, must first 203 | this.props.data = this.rebuildData(this.props.data) 204 | 205 | // rebuild template 206 | this.rebuildTemplate() 207 | 208 | // itemSize event 209 | this.setEvent("itemSize", (sender, indexPath) => { 210 | const info = sender.object(indexPath)?.__title?.info 211 | if (info?.title) { 212 | return $size(Math.max($device.info.screen.width, $device.info.screen.height), 0) 213 | } 214 | const columns = this.props.columns ?? 2 215 | const spacing = this.props.spacing ?? 15 216 | const width = 217 | this.props.itemWidth ?? 218 | this.props.itemSize?.width ?? 219 | (sender.super.frame.width - spacing * (columns + 1)) / columns 220 | const height = this.props.itemHeight ?? this.props.itemSize?.height ?? 100 221 | return $size(width, height) 222 | }) 223 | 224 | return this 225 | } 226 | } 227 | 228 | module.exports = { 229 | Matrix 230 | } 231 | -------------------------------------------------------------------------------- /docs/setting.md: -------------------------------------------------------------------------------- 1 | # Setting 2 | 3 | > `Setting` 继承自 `Controller`,提供一个设置功能的 UI 页面和数据存储功能。 4 | 5 | ## Usage 6 | 7 | ### Methods 8 | 9 | > 组件控制器方法 10 | 11 | - `constructor(args = {})` 12 | 13 | 初始化, args 对象中参数均有 `setXxx(value): this` 实现,如 `setSavePath(path)` 返回值为对象自身,因此可以链式调用。 14 | 15 | **Args** 16 | 17 | - `set` 18 | 19 | 可用来重写 set 方法,必须同时设置 get 方法。 20 | 21 | 设置此参数将导致 savePath 参数失效。 22 | 23 | - `get` 24 | 25 | 可用来重写 get 方法,必须同时设置 set 方法。 26 | 27 | 设置此参数将导致 savePath 参数失效。 28 | 29 | - `savePath` 30 | 31 | 数据文件保存路径。 32 | 33 | 默认值: "storage/setting.json" 34 | 35 | - `structure` 36 | 37 | 结构数据。`structure` 优先级高于 `structurePath` 38 | 39 | 若不提供则从 `structurePath` 读取数据。 40 | 41 | - `structurePath` 42 | 43 | 结构数据文件路径,如果设置了 `structure` 会使该参数失效。 44 | 45 | 默认值: "setting.json" 46 | 47 | - `name` 48 | 49 | 实例唯一名称,若不提供则自动生成。 50 | 51 | - `get(key, _default = null)` 52 | 53 | 根据 `key` 获取值。若未找到将返回 `_default`。 54 | 55 | - `set(key, value)` 56 | 57 | 不建议使用该方法,所有数据更新推荐仅在生成的 UI 中完成。 58 | 59 | 设置键值对,该方法会将数据保存到文件,同时更新内存中的数据,这意味着设置即时生效,可随时调用 `get()` 获取数据。 60 | 61 | - `useJsboxNav()` 62 | 63 | 调用后将修改 child 类型弹出方式为 JsBox 默认样式。 64 | 65 | - `setReadonly()` 66 | 67 | 设置为只读模式,尝试写入数据将抛出 `SettingReadonlyError` 错误。 68 | 69 | - `setFooter(footer)` 70 | 71 | 用来设置页脚视图,若不调用,将提供默认样式,显示作者和版本号(作者和版本号将从根目录的 `config.js` 获取)。 72 | 73 | ### Events 74 | 75 | > 通过父类中的方法 `setEvent(event, callback)` 进行设置,详见 [Controller](./controller.md) 76 | 77 | - `onSet(key, value)` 78 | 79 | 当更新键值对时触发。 80 | 81 | ### Structure 82 | 83 | > 组件提供的设置项类型,保存在`setting.json`文件或实例化类时提供的文件中。 84 | 85 | #### switch 86 | 87 | ```json 88 | { 89 | "icon": [ 90 | "archivebox", 91 | "#336699" 92 | ], 93 | "title": "USE_COMPRESSED_IMAGE", 94 | "type": "switch", 95 | "key": "album.useCompressedImage", 96 | "value": true 97 | } 98 | ``` 99 | 100 | #### stepper 101 | 102 | ```json 103 | { 104 | "icon": [ 105 | "rectangle.split.3x1.fill", 106 | "#FF6666" 107 | ], 108 | "title": "SWITCH_INTERVAL", 109 | "type": "stepper", 110 | "key": "album.switchInterval", 111 | "min": 10, 112 | "max": 60, 113 | "value": 10 114 | } 115 | ``` 116 | 117 | #### string 118 | 119 | ```json 120 | { 121 | "icon": [ 122 | "link", 123 | "#CC6699" 124 | ], 125 | "title": "URL_SCHEME", 126 | "type": "string", 127 | "key": "album.urlScheme", 128 | "value": "" 129 | } 130 | ``` 131 | 132 | #### number 133 | 134 | ```json 135 | { 136 | "icon": [ 137 | "rectangle.split.3x1.fill", 138 | "#FF6666" 139 | ], 140 | "title": "TIME_SPAN", 141 | "type": "number", 142 | "key": "timeSpan", 143 | "value": 10 144 | } 145 | ``` 146 | 147 | #### info 148 | 149 | ```json 150 | { 151 | "icon": [ 152 | "book.fill", 153 | "#A569BD" 154 | ], 155 | "title": "Info", 156 | "type": "info", 157 | "value": "Text message." 158 | } 159 | ``` 160 | 161 | #### script 162 | 163 | 如果 `value` 以 `this.method` 开头且结尾无括号,则会自动向该函数传递一个 `animate` 对象。 164 | 165 | `this` 为 `Setting` 实例,需要向 `setting.method` 写入方法,如: 166 | 167 | ```js 168 | setting.method.readme = async animate => { 169 | console.log("Hello") 170 | await $wait(1) 171 | console.log("World") 172 | } 173 | ``` 174 | 175 | 框架会等待函数执行完毕并自动处理列表项点击高亮动画 176 | 177 | 其中,`animate` 定义如下: 178 | 179 | ```js 180 | const animate = { 181 | start: callable(), // 会出现加载动画 182 | cancel: callable(), // 会直接恢复箭头图标 183 | done: callable() // 会出现对号,然后恢复箭头 184 | } 185 | ``` 186 | 187 | ```json 188 | { 189 | "icon": [ 190 | "book.fill", 191 | "#A569BD" 192 | ], 193 | "title": "README", 194 | "type": "script", 195 | "value": "this.method.readme" 196 | } 197 | ``` 198 | 199 | `script` 类型支持 async 异步函数 200 | 201 | ```json 202 | { 203 | "icon": [ 204 | "book.fill", 205 | "#A569BD" 206 | ], 207 | "title": "README_ASYNC", 208 | "type": "script", 209 | "value": "const content=await getReadme();show(content)" 210 | } 211 | ``` 212 | 213 | #### tab 214 | 215 | `items` 若为字符串,将尝试将其作为函数执行并使用其返回值。 216 | 217 | 支持 `script` 类型的 `this.method`,其他形式则应为函数,如 `() => [ "Hello World" ]` 218 | 219 | 可选参数 `values`, 与 `items` 一一对应的数组,同样可传入字符串代码动态执行。 220 | 221 | 传入 `values` 后,`get` 函数将返回 `values` 内的值。 222 | 223 | ```json 224 | { 225 | "icon": [ 226 | "flag.fill", 227 | "#FFCC00" 228 | ], 229 | "title": "FIRST_DAY_OF_WEEK", 230 | "type": "tab", 231 | "key": "calendar.firstDayOfWeek", 232 | "items": [ 233 | "_SUNDAY", 234 | "_MONDAY" 235 | ], 236 | "values": [ 237 | "sunday", 238 | "monday" 239 | ], 240 | "value": "sunday" 241 | } 242 | ``` 243 | 244 | 以上示例中,`get` 函数在选中 `"_SUNDAY"` 时将会返回 `"sunday"`, 若未定义 `values` 则返回数字 `0`。 245 | 246 | #### menu 247 | 248 | 参数 `items`, `values`, 与 `tab` 类型相同。 249 | 250 | ```json 251 | { 252 | "icon": [ 253 | "rectangle.3.offgrid.fill" 254 | ], 255 | "title": "RIGHT", 256 | "type": "menu", 257 | "key": "right", 258 | "items": "this.method.getMenu", 259 | "value": 0 260 | } 261 | ``` 262 | 263 | 可选参数 `pullDown`,默认 `false`。 264 | 265 | 启用后,`menu` 项将以 Pull-Down 菜单样式显示,选项内容将不可动态更改。 266 | 267 | #### color 268 | 269 | 调用 `get(key, _default = null)` 方法返回 `$color` 对象。 270 | 271 | ```json 272 | { 273 | "icon": [ 274 | "wand.and.rays", 275 | "orange" 276 | ], 277 | "title": "COLOR_TONE", 278 | "type": "color", 279 | "key": "calendar.colorTone", 280 | "value": "orange" 281 | } 282 | ``` 283 | 284 | #### date 285 | 286 | ```json 287 | { 288 | "icon": [ 289 | "calendar", 290 | "#99CC33" 291 | ], 292 | "title": "CHOOSE_DATE", 293 | "type": "date", 294 | "key": "date", 295 | "mode": 1, 296 | "value": 0 297 | } 298 | ``` 299 | 300 | #### input 301 | 302 | `secure` 为 `true` 时将以密码框的形式显示。 303 | 304 | ```json 305 | { 306 | "icon": [ 307 | "pencil.and.ellipsis.rectangle", 308 | "#A569BD" 309 | ], 310 | "title": "TITLE", 311 | "type": "input", 312 | "secure": false, 313 | "key": "title", 314 | "value": "Title" 315 | } 316 | ``` 317 | 318 | #### icon 319 | 320 | ```json 321 | { 322 | "icon": [ 323 | "rectangle.3.offgrid.fill" 324 | ], 325 | "title": "ICON", 326 | "type": "icon", 327 | "key": "icon", 328 | "value": "plus" 329 | } 330 | ``` 331 | 332 | #### push 333 | 334 | 如果 `view` 以 `this.method` 开头且结尾无括号,则会执函数获取子视图,和 `script` 一样需要向 `setting.method` 写入方法。 335 | 336 | ```js 337 | setting.method.readme = animate => { 338 | return { 339 | type: "view", 340 | props: {}, 341 | layout: $layout.fill 342 | } 343 | } 344 | ``` 345 | 346 | ```json 347 | { 348 | "icon": [ 349 | "rectangle.3.offgrid.fill" 350 | ], 351 | "title": "CHILD", 352 | "type": "push", 353 | "key": "my.push", 354 | "value": "data.save.in.my.push", 355 | "navButtons": "this.method.getMyNavButtons", 356 | "view": "this.method.getMyView" 357 | } 358 | ``` 359 | 360 | #### child 361 | 362 | ```json 363 | { 364 | "icon": [ 365 | "rectangle.3.offgrid.fill" 366 | ], 367 | "title": "CHILD", 368 | "type": "child", 369 | "children": [ 370 | { 371 | "title": "Section 1", 372 | "items": [] 373 | }, 374 | { 375 | "title": "Section 2", 376 | "items": [] 377 | } 378 | ] 379 | } 380 | ``` 381 | 382 | #### image 383 | 384 | 调用 `get(key, _default = null)` 方法返回 `$image` 对象。 385 | 386 | ```json 387 | { 388 | "icon": [ 389 | "photo" 390 | ], 391 | "title": "IMAGE", 392 | "type": "image", 393 | "key": "image" 394 | } 395 | ``` 396 | 397 | ## 示例 398 | 399 | ```js 400 | const { Setting } = require("./easy-jsbox") 401 | const setting = new Setting() 402 | setting 403 | .setSavePath("/storage/setting.json") 404 | .setStructure([ 405 | { 406 | "title": "My setting", 407 | "items": [ 408 | { 409 | "icon": [ 410 | "house", 411 | "white" 412 | ], 413 | "title": "Hello", 414 | "type": "string", 415 | "key": "hello", 416 | "value": "" 417 | } 418 | ] 419 | } 420 | ]) 421 | .loadConfig() 422 | setting.set("hello", "world") // 不建议使用 423 | console.log(setting.get("hello")) 424 | ``` 425 | 426 | 当调用 `setting.loadConfig()` 后将会从 `this.structure` 初始化数据,此时便可正常使用 `get(key, _default = null)`、`set(key, value)` 方法进行数据读写。 427 | -------------------------------------------------------------------------------- /src/webdav.js: -------------------------------------------------------------------------------- 1 | const { Request } = require("./request") 2 | 3 | class WebDAV extends Request { 4 | /** 5 | * @type {string} 6 | */ 7 | #host 8 | user 9 | password 10 | /** 11 | * @type {string} 12 | */ 13 | #basepath 14 | 15 | namespace = "JSBox.WebDAV" 16 | lockTokenCacheKey = this.namespace + ".lockToken" 17 | 18 | get host() { 19 | return this.#host 20 | } 21 | set host(host) { 22 | this.#host = host.trim() 23 | while (this.#host.endsWith("/")) { 24 | this.#host = this.#host.substring(0, this.#host.length - 1) 25 | } 26 | if (!this.#host.startsWith("http")) { 27 | this.#host = "http://" + this.#host 28 | } 29 | } 30 | get basepath() { 31 | return this.#basepath 32 | } 33 | set basepath(basepath) { 34 | this.#basepath = basepath.trim() 35 | while (this.#basepath.endsWith("/")) { 36 | this.#basepath = this.#basepath.substring(0, this.#basepath.length - 1) 37 | } 38 | while (this.#basepath.startsWith("/")) { 39 | this.#basepath = this.#basepath.substring(1) 40 | } 41 | this.#basepath = "/" + this.#basepath 42 | } 43 | 44 | constructor({ host, user, password, basepath = "" } = {}) { 45 | super() 46 | 47 | this.host = host 48 | this.user = user 49 | this.password = password 50 | this.basepath = basepath 51 | } 52 | 53 | #getPath(path) { 54 | path = path.trim() 55 | path = path.startsWith("/") ? path : "/" + path 56 | return this.basepath + path 57 | } 58 | 59 | /** 60 | * 61 | * @param {string} path 62 | * @param {string} method 63 | * @param {object} body 64 | * @param {object} header 65 | * @returns 66 | */ 67 | async request(path, method, body = null, header = {}) { 68 | header = Object.assign( 69 | { 70 | "Content-Type": "text/xml; charset=UTF-8", 71 | Authorization: "Basic " + $text.base64Encode(`${this.user}:${this.password}`) 72 | }, 73 | header 74 | ) 75 | return await super.request(this.host + this.#getPath(path), method, body, header) 76 | } 77 | 78 | /** 79 | * 80 | * @returns {[string]} 81 | */ 82 | async allow(path) { 83 | const resp = await this.request(path, Request.method.options) 84 | const allow = resp.response.headers?.allow ?? resp.response.headers?.Allow 85 | return allow?.split(",").map(item => item.trim().toUpperCase()) ?? [] 86 | } 87 | 88 | async propfind(path, props = [], depth = 0) { 89 | if (!Array.isArray(props)) { 90 | props = [props] 91 | } 92 | const propString = props.map(prop => ``).join() 93 | const body = `${propString}` 94 | const resp = await this.request(path, "PROPFIND", body, { Depth: depth }) 95 | return $xml.parse({ string: resp.data }) 96 | } 97 | async propfindAll(path, depth = 0) { 98 | const body = `` 99 | const resp = await this.request(path, "PROPFIND", body, { Depth: depth }) 100 | return $xml.parse({ string: resp.data }) 101 | } 102 | 103 | async ls(path, depth = 1) { 104 | const resp = await this.request(path, "PROPFIND", null, { Depth: depth }) 105 | return $xml.parse({ string: resp.data }) 106 | } 107 | 108 | /** 109 | * 110 | * @returns {boolean} 111 | */ 112 | async exists(path) { 113 | try { 114 | const allow = await this.allow(path) 115 | if (allow.includes(Request.method.get)) { 116 | await this.request(path, Request.method.head) 117 | } else { 118 | await this.ls(path, 0) 119 | } 120 | return true 121 | } catch (error) { 122 | if (error?.code === 404) { 123 | return false 124 | } 125 | throw error 126 | } 127 | } 128 | 129 | async mkdir(path) { 130 | return await this.request(path, "MKCOL") 131 | } 132 | 133 | async get(path) { 134 | return await this.request(path, Request.method.get, null) 135 | } 136 | 137 | async put(path, body, { withLock = true, waitInterval = 2, maxTry = 3 } = {}) { 138 | let header = {} 139 | while (true) { 140 | let fileLock = await this.isLocked(path) 141 | if (!fileLock) { 142 | break 143 | } 144 | if (--maxTry <= 0) { 145 | throw new Error("Resource Locked") 146 | } 147 | await $wait(waitInterval) 148 | } 149 | 150 | if (withLock) { 151 | try { 152 | await this.lock(path) 153 | header["If"] = `(${this.#getLockToken(path)})` 154 | } catch (error) { 155 | if (error.code !== 404) { 156 | throw error 157 | } 158 | withLock = false // 跳过解锁步骤 159 | } 160 | } 161 | 162 | await this.request(path, Request.method.put, body, header) 163 | 164 | if (withLock) await this.unlock(path) 165 | } 166 | 167 | async delete(path) { 168 | if (!path) { 169 | throw new Error("path empty") 170 | } 171 | return await this.request(path, Request.method.delete) 172 | } 173 | 174 | #setLockToken(path, token) { 175 | const lockToken = $cache.get(this.lockTokenCacheKey) ?? {} 176 | lockToken[path] = token 177 | $cache.set(this.lockTokenCacheKey, lockToken) 178 | } 179 | #getLockToken(path) { 180 | const lockToken = $cache.get(this.lockTokenCacheKey) ?? {} 181 | return lockToken[path] 182 | } 183 | async isSupportLock(path) { 184 | try { 185 | const resp = await this.propfind(path, "supportedlock") 186 | const rootElement = resp.rootElement 187 | const lockentry = rootElement.firstChild({ 188 | xPath: "//D:response/D:propstat/D:prop/D:supportedlock/D:lockentry" 189 | }) 190 | const write = lockentry.firstChild({ xPath: "//D:locktype/D:write" }) 191 | return write ? true : false 192 | } catch (error) { 193 | if (error.code !== 404) { 194 | return false 195 | } else { 196 | throw error 197 | } 198 | } 199 | } 200 | async lock(path, { infinity = false, timeout = "Second-10" } = {}) { 201 | const isSupportLock = await this.isSupportLock(path) 202 | if (!isSupportLock) { 203 | throw new Error("Your WebDAV service does not support the `LOCK` method.") 204 | } 205 | 206 | const body = `${this.namespace}` 207 | const resp = await this.request(path, "LOCK", body, { 208 | Timeout: timeout, 209 | Depth: infinity ? "infinity" : 0 210 | }) 211 | const token = resp.response.headers["lock-token"] ?? resp.response.headers["Lock-Token"] 212 | this.#setLockToken(path, token) 213 | return $xml.parse({ string: resp.data }) 214 | } 215 | async isLocked(path) { 216 | try { 217 | const resp = await this.propfind(path, "lockdiscovery") 218 | const rootElement = resp.rootElement 219 | const status = rootElement.firstChild({ 220 | xPath: "//D:response/D:propstat/D:status" 221 | }).string 222 | const supported = !status.includes("404") 223 | if (supported) { 224 | // TODO lockdiscovery 225 | const lockdiscovery = rootElement.firstChild({ 226 | xPath: "//D:response/D:propstat/D:prop/D:lockdiscovery" 227 | }) 228 | const activelocks = lockdiscovery.children() ?? [] 229 | for (let i = 0; i < activelocks.length; i++) { 230 | const lockroot = activelocks[i].firstChild({ tag: "lockroot" }).string 231 | if (lockroot === this.host + this.#getPath(path)) { 232 | return true 233 | } 234 | } 235 | } else { 236 | // unsupport lockdiscovery 237 | await this.lock(path, { timeout: "Second-0" }) 238 | } 239 | } catch (error) { 240 | if (error.code === 423) { 241 | return true 242 | } 243 | } 244 | return false 245 | } 246 | async refreshLock(path, { infinity = false, timeout = "Second-10" } = {}) { 247 | const resp = await this.request(path, "LOCK", null, { 248 | Timeout: timeout, 249 | If: this.#getLockToken(path), 250 | Depth: infinity ? "infinity" : 0 251 | }) 252 | return $xml.parse({ string: resp.data }) 253 | } 254 | async unlock(path) { 255 | await this.request(path, "UNLOCK", null, { 256 | "Lock-Token": this.#getLockToken(path) 257 | }) 258 | } 259 | } 260 | 261 | module.exports = { WebDAV } 262 | -------------------------------------------------------------------------------- /src/ui-kit.js: -------------------------------------------------------------------------------- 1 | class UIKit { 2 | static #sharedApplication = $objc("UIApplication").$sharedApplication() 3 | static #feedbackGenerator = $objc("UINotificationFeedbackGenerator").$new() 4 | 5 | static feedbackSuccess() { 6 | UIKit.#feedbackGenerator.$notificationOccurred(0) 7 | } 8 | static feedbackError() { 9 | UIKit.#feedbackGenerator.$notificationOccurred(2) 10 | } 11 | 12 | /** 13 | * @type {boolean} 14 | */ 15 | static isTaio = $app.info.bundleID.includes("taio") 16 | 17 | /** 18 | * 对齐方式 19 | */ 20 | static align = { left: 0, right: 1, top: 2, bottom: 3 } 21 | 22 | /** 23 | * 默认文本颜色 24 | */ 25 | static textColor = $color("primaryText") 26 | 27 | /** 28 | * 默认链接颜色 29 | */ 30 | static linkColor = $color("systemLink") 31 | static primaryViewBackgroundColor = $color("primarySurface") 32 | static scrollViewBackgroundColor = $color("insetGroupedBackground") 33 | 34 | /** 35 | * 可滚动视图列表 36 | * @type {string[]} 37 | */ 38 | static scrollViewList = ["list", "matrix"] 39 | 40 | /** 41 | * 是否属于大屏设备 42 | * @type {boolean} 43 | */ 44 | static isLargeScreen = $device.isIpad || $device.isIpadPro 45 | 46 | /** 47 | * 获取Window大小 48 | */ 49 | static get windowSize() { 50 | return $objc("UIWindow").$keyWindow().jsValue().size 51 | } 52 | 53 | static NavigationBarNormalHeight = $objc("UINavigationController").invoke("alloc.init").$navigationBar().jsValue() 54 | .frame.height 55 | static NavigationBarLargeTitleHeight = 56 | $objc("UITabBarController").invoke("alloc.init").$tabBar().jsValue().frame.height + 57 | UIKit.NavigationBarNormalHeight 58 | static PageSheetNavigationBarNormalHeight = 56 59 | 60 | /** 61 | * 判断是否是分屏模式 62 | * @type {boolean} 63 | */ 64 | static get isSplitScreenMode() { 65 | return UIKit.isLargeScreen && $device.info.screen.width !== UIKit.windowSize.width 66 | } 67 | 68 | static get topSafeAreaInsets() { 69 | // Taio 不是全屏运行,故为 0 70 | return UIKit.isTaio ? 0 : UIKit.#sharedApplication?.$keyWindow()?.$safeAreaInsets()?.top ?? 0 71 | } 72 | 73 | static get bottomSafeAreaInsets() { 74 | return UIKit.#sharedApplication?.$keyWindow()?.$safeAreaInsets()?.bottom ?? 0 75 | } 76 | 77 | static get statusBarOrientation() { 78 | return UIKit.#sharedApplication.$statusBarOrientation() 79 | } 80 | 81 | /** 82 | * 调试模式控制台高度 83 | * @type {number} 84 | */ 85 | static get consoleBarHeight() { 86 | if ($app.isDebugging) { 87 | let height = UIKit.#sharedApplication.$statusBarFrame().height + 26 88 | if ($device.isIphoneX) { 89 | height += 30 90 | } 91 | return height 92 | } 93 | return 0 94 | } 95 | 96 | static get isHorizontal() { 97 | return UIKit.statusBarOrientation === 3 || UIKit.statusBarOrientation === 4 98 | } 99 | 100 | static loading() { 101 | const loading = $ui.create( 102 | UIKit.blurBox( 103 | { 104 | cornerRadius: 15 105 | }, 106 | [ 107 | { 108 | type: "spinner", 109 | props: { 110 | loading: true, 111 | style: 0 112 | }, 113 | layout: (make, view) => { 114 | make.size.equalTo(view.prev) 115 | make.center.equalTo(view.super) 116 | } 117 | } 118 | ] 119 | ) 120 | ) 121 | 122 | return { 123 | start: () => { 124 | $ui.controller.view.insertAtIndex(loading, 0) 125 | loading.layout((make, view) => { 126 | make.center.equalTo(view.super) 127 | const size = Math.min(Math.min(UIKit.windowSize.width, UIKit.windowSize.height) * 0.6, 260) 128 | make.size.equalTo($size(size, size)) 129 | }) 130 | loading.moveToFront() 131 | }, 132 | end: () => { 133 | loading.remove() 134 | } 135 | } 136 | } 137 | 138 | static defaultBackgroundColor(type) { 139 | return UIKit.scrollViewList.indexOf(type) > -1 140 | ? UIKit.scrollViewBackgroundColor 141 | : UIKit.primaryViewBackgroundColor 142 | } 143 | 144 | static separatorLine(props = {}, align = UIKit.align.bottom) { 145 | return { 146 | // canvas 147 | type: "canvas", 148 | props: props, 149 | layout: (make, view) => { 150 | if (view.prev === undefined) { 151 | make.top.equalTo(view.super) 152 | } else if (align === UIKit.align.bottom) { 153 | make.top.equalTo(view.prev.bottom) 154 | } else { 155 | make.top.equalTo(view.prev.top) 156 | } 157 | make.height.equalTo(1 / $device.info.screen.scale) 158 | make.left.right.inset(0) 159 | }, 160 | events: { 161 | draw: (view, ctx) => { 162 | ctx.strokeColor = props.bgcolor ?? $color("separatorColor") 163 | ctx.setLineWidth(1) 164 | ctx.moveToPoint(0, 0) 165 | ctx.addLineToPoint(view.frame.width, 0) 166 | ctx.strokePath() 167 | } 168 | } 169 | } 170 | } 171 | 172 | static blurBox(props = {}, views = [], layout = $layout.fill) { 173 | return { 174 | type: "blur", 175 | props: Object.assign( 176 | { 177 | style: $blurStyle.thinMaterial 178 | }, 179 | props 180 | ), 181 | views, 182 | layout 183 | } 184 | } 185 | 186 | /** 187 | * 计算文本尺寸 188 | * @param {$font} font 189 | * @param {string} content 190 | * @returns 191 | */ 192 | static getContentSize(font, content = "A", width = UIKit.windowSize.width, lineSpacing = undefined) { 193 | const options = { 194 | text: content, 195 | width, 196 | font: font 197 | } 198 | if (lineSpacing !== undefined) { 199 | options.lineSpacing = lineSpacing 200 | } 201 | return $text.sizeThatFits(options) 202 | } 203 | 204 | static getSymbolSize(symbol, size) { 205 | symbol = typeof symbol === "string" ? $image(symbol) : symbol 206 | const scale = symbol.size.width / symbol.size.height 207 | if (symbol.size.width > symbol.size.height) { 208 | return $size(size, size / scale) 209 | } else { 210 | return $size(size * scale, size) 211 | } 212 | } 213 | 214 | /** 215 | * 建议仅在使用 JSBox nav 时使用,便于统一风格 216 | */ 217 | static push({ 218 | views, 219 | statusBarStyle = 0, 220 | title = "", 221 | navButtons = [{ title: "" }], 222 | bgcolor = views[0]?.props?.bgcolor ?? "primarySurface", 223 | titleView = undefined, 224 | disappeared 225 | } = {}) { 226 | const props = { 227 | statusBarStyle, 228 | navButtons, 229 | title, 230 | bgcolor: typeof bgcolor === "string" ? $color(bgcolor) : bgcolor 231 | } 232 | if (titleView) { 233 | props.titleView = titleView 234 | } 235 | $ui.push({ 236 | props, 237 | events: { 238 | disappeared: () => { 239 | if (disappeared !== undefined) disappeared() 240 | } 241 | }, 242 | views: [ 243 | { 244 | type: "view", 245 | views: views, 246 | layout: (make, view) => { 247 | make.top.equalTo(view.super.safeArea) 248 | make.bottom.equalTo(view.super) 249 | make.left.right.equalTo(view.super.safeArea) 250 | } 251 | } 252 | ] 253 | }) 254 | } 255 | 256 | /** 257 | * 压缩图片 258 | * @param {$image} image $image 259 | * @param {number} maxSize 图片最大尺寸 单位:像素 260 | * @returns {$image} 261 | */ 262 | static compressImage(image, maxSize = 1280 * 720) { 263 | const info = $imagekit.info(image) 264 | if (info.height * info.width > maxSize) { 265 | const scale = maxSize / (info.height * info.width) 266 | image = $imagekit.scaleBy(image, scale) 267 | } 268 | return image 269 | } 270 | 271 | static deleteConfirm(message, conformAction) { 272 | $ui.alert({ 273 | title: $l10n("DELETE_CONFIRM_TITLE"), 274 | message, 275 | actions: [ 276 | { 277 | title: $l10n("DELETE"), 278 | style: $alertActionType.destructive, 279 | handler: () => { 280 | conformAction() 281 | } 282 | }, 283 | { title: $l10n("CANCEL") } 284 | ] 285 | }) 286 | } 287 | 288 | static bytesToSize(bytes) { 289 | if (bytes === 0) return "0 B" 290 | const k = 1024, 291 | sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"], 292 | i = Math.floor(Math.log(bytes) / Math.log(k)) 293 | 294 | return (bytes / Math.pow(k, i)).toPrecision(3) + " " + sizes[i] 295 | } 296 | } 297 | 298 | module.exports = { 299 | UIKit 300 | } 301 | -------------------------------------------------------------------------------- /src/file-manager.js: -------------------------------------------------------------------------------- 1 | const { UIKit } = require("./ui-kit") 2 | const { Sheet } = require("./sheet") 3 | const { NavigationView } = require("./navigation-view/navigation-view") 4 | const { NavigationBar } = require("./navigation-view/navigation-bar") 5 | 6 | /** 7 | * @typedef {import("./navigation-view/view-controller").ViewController} ViewController 8 | */ 9 | 10 | class FileManager { 11 | /** 12 | * @type {ViewController} 13 | */ 14 | viewController 15 | 16 | constructor() { 17 | this.edges = 10 18 | this.iconSize = 25 19 | } 20 | 21 | /** 22 | * 23 | * @param {ViewController} viewController 24 | */ 25 | setViewController(viewController) { 26 | this.viewController = viewController 27 | } 28 | 29 | get menu() { 30 | return { 31 | items: [ 32 | { 33 | title: $l10n("SHARE"), 34 | symbol: "square.and.arrow.up", 35 | handler: async (sender, indexPath) => { 36 | const info = sender.object(indexPath).info.info 37 | $share.sheet([$file.absolutePath(info.path)]) 38 | } 39 | } 40 | ] 41 | } 42 | } 43 | 44 | delete(info) { 45 | $file.delete(info.path) 46 | } 47 | 48 | edit(info) { 49 | const file = $file.read(info.path) 50 | if (file.info?.mimeType?.startsWith("image")) { 51 | Sheet.quickLookImage(file, info.path.substring(info.path.lastIndexOf("/") + 1)) 52 | } else { 53 | const sheet = new Sheet() 54 | const id = $text.uuid 55 | sheet 56 | .setView({ 57 | type: "code", 58 | layout: $layout.fill, 59 | props: { 60 | id: id, 61 | lineNumbers: true, 62 | theme: $device.isDarkMode ? "atom-one-dark" : "atom-one-light", 63 | text: file.string, 64 | insets: $insets(15, 15, 15, 15) 65 | } 66 | }) 67 | .addNavBar({ 68 | title: info.file, 69 | popButton: { 70 | title: $l10n("CLOSE") 71 | }, 72 | rightButtons: [ 73 | { 74 | title: $l10n("SAVE"), 75 | tapped: () => { 76 | $file.write({ 77 | data: $data({ string: $(id).text }), 78 | path: info.path 79 | }) 80 | $ui.success($l10n("SAVE_SUCCESS")) 81 | } 82 | } 83 | ] 84 | }) 85 | sheet.init().present() 86 | } 87 | } 88 | 89 | getFiles(basePath = "") { 90 | return $file 91 | .list(basePath) 92 | .map(file => { 93 | const path = basePath + "/" + file 94 | const isDirectory = $file.isDirectory(path) 95 | return { 96 | info: { info: { path, file, isDirectory } }, 97 | icon: { symbol: isDirectory ? "folder.fill" : "doc" }, 98 | name: { text: file }, 99 | size: { text: isDirectory ? "" : "--" } 100 | } 101 | }) 102 | .sort((a, b) => { 103 | if (a.info.info.isDirectory !== b.info.info.isDirectory) { 104 | return a.info.info.isDirectory ? -1 : 1 105 | } 106 | 107 | if (a.info.info.isDirectory === b.info.info.isDirectory) { 108 | return a.info.info.file.localeCompare(b.info.info.file) 109 | } 110 | }) 111 | } 112 | 113 | async loadFileSize(data) { 114 | data.map((item, i) => { 115 | const info = item.info.info 116 | if (!info.isDirectory) { 117 | try { 118 | data[i].size.text = UIKit.bytesToSize($file.read(info.path).info.size) 119 | } catch (error) { 120 | data[i].size.text = error 121 | } 122 | } 123 | }) 124 | return data 125 | } 126 | 127 | get listTemplate() { 128 | return { 129 | props: { bgcolor: $color("clear") }, 130 | views: [ 131 | { props: { id: "info" } }, 132 | { 133 | type: "image", 134 | props: { 135 | id: "icon" 136 | }, 137 | layout: (make, view) => { 138 | make.centerY.equalTo(view.super) 139 | make.left.inset(this.edges) 140 | make.size.equalTo(this.iconSize) 141 | } 142 | }, 143 | { 144 | type: "view", 145 | views: [ 146 | { 147 | type: "label", 148 | props: { 149 | id: "size", 150 | color: $color("secondaryText"), 151 | lines: 1 152 | }, 153 | layout: (make, view) => { 154 | make.height.equalTo(view.super) 155 | make.right.inset(this.edges) 156 | } 157 | }, 158 | { 159 | type: "label", 160 | props: { 161 | id: "name", 162 | lines: 1 163 | }, 164 | layout: (make, view) => { 165 | make.height.left.equalTo(view.super) 166 | make.right.equalTo(view.prev.left).offset(-this.edges) 167 | } 168 | } 169 | ], 170 | layout: (make, view) => { 171 | make.height.right.equalTo(view.super) 172 | make.left.equalTo(view.prev.right).offset(this.edges) 173 | } 174 | } 175 | ] 176 | } 177 | } 178 | 179 | #pushPage(basePath) { 180 | const lastSlash = basePath.lastIndexOf("/") 181 | const title = basePath.substring(lastSlash < 0 ? 0 : lastSlash + 1) 182 | const view = this.getListView(basePath) 183 | const tapped = async () => { 184 | const path = $file.absolutePath(basePath) 185 | const result = await $ui.alert({ 186 | title: "Path", 187 | message: path, 188 | actions: [{ title: $l10n("COPY") }, { title: $l10n("OK") }] 189 | }) 190 | if (result.index === 0) { 191 | $clipboard.text = path 192 | $ui.toast($l10n("COPIED")) 193 | } 194 | } 195 | 196 | if (this.viewController) { 197 | const nv = new NavigationView() 198 | nv.setView(view).navigationBarTitle(title) 199 | nv.navigationBar.setLargeTitleDisplayMode(NavigationBar.largeTitleDisplayModeNever) 200 | nv.navigationBarItems.addRightButton({ 201 | symbol: "info.circle", 202 | tapped 203 | }) 204 | this.viewController.push(nv) 205 | } else { 206 | UIKit.push({ 207 | title, 208 | views: [view], 209 | navButtons: [ 210 | { 211 | symbol: "info.circle", 212 | handler: tapped 213 | } 214 | ] 215 | }) 216 | } 217 | } 218 | 219 | getListView(basePath = "") { 220 | return { 221 | // 剪切板列表 222 | type: "list", 223 | props: { 224 | menu: this.menu, 225 | bgcolor: UIKit.primaryViewBackgroundColor, 226 | separatorInset: $insets(0, this.edges, 0, 0), 227 | data: [], 228 | template: this.listTemplate, 229 | actions: [ 230 | { 231 | // 删除 232 | title: " " + $l10n("DELETE") + " ", // 防止JSBox自动更改成默认的删除操作 233 | color: $color("red"), 234 | handler: (sender, indexPath) => { 235 | const info = sender.object(indexPath).info.info 236 | UIKit.deleteConfirm( 237 | $l10n("FILE_MANAGER_DELETE_CONFIRM_MSG") + ' "' + info.file + '" ?', 238 | () => { 239 | this.delete(info) 240 | sender.delete(indexPath) 241 | } 242 | ) 243 | } 244 | } 245 | ] 246 | }, 247 | layout: $layout.fill, 248 | events: { 249 | ready: sender => { 250 | const data = this.getFiles(basePath) 251 | sender.data = data 252 | this.loadFileSize(data).then(data => { 253 | sender.data = data 254 | }) 255 | }, 256 | pulled: async sender => { 257 | const data = this.getFiles(basePath) 258 | sender.data = data 259 | sender.data = await this.loadFileSize(data) 260 | $delay(0.5, () => { 261 | sender.endRefreshing() 262 | }) 263 | }, 264 | didSelect: (sender, indexPath, data) => { 265 | const info = data.info.info 266 | if (info.isDirectory) { 267 | this.#pushPage(info.path) 268 | } else { 269 | this.edit(info) 270 | } 271 | } 272 | } 273 | } 274 | } 275 | 276 | /** 277 | * @param {string} basePath JSBox path 278 | */ 279 | push(basePath = "") { 280 | this.#pushPage(basePath) 281 | } 282 | } 283 | 284 | module.exports = { 285 | FileManager 286 | } 287 | -------------------------------------------------------------------------------- /src/alert.js: -------------------------------------------------------------------------------- 1 | const { UIKit } = require("./ui-kit") 2 | 3 | class AlertAction { 4 | title 5 | handler 6 | 7 | constructor({ title = $l10n("OK"), disabled = false, style = $alertActionType.default, handler = () => {} } = {}) { 8 | this.title = title 9 | this.disabled = disabled 10 | this.style = style 11 | this.handler = handler 12 | } 13 | } 14 | 15 | class Alert { 16 | id = $text.uuid 17 | 18 | title 19 | message 20 | actions 21 | 22 | titleFont = $font("bold", 20) 23 | messageFont = $font(14) 24 | actionButtonFontSize = 16 25 | actionButtonHighlight = $color($rgba(0, 0, 0, 0.2), $rgba(255, 255, 255, 0.2)) 26 | actionButtonHeight = 35 27 | actionButtonBoderWidth = 0.5 28 | actionButtonBorderColor = $color("#C9C9C9", "#383838") 29 | boxWidth = 250 30 | textVerticalMargin = 20 31 | textHorizontalMargin = 50 32 | 33 | constructor({ title = "", message = "", actions = [] } = {}) { 34 | this.title = title 35 | this.message = message 36 | this.actions = actions 37 | if (this.actions.length === 0) { 38 | this.actions.push(new AlertAction()) 39 | } 40 | this.titleSize = UIKit.getContentSize(this.titleFont, this.title, this.boxWidth - this.textHorizontalMargin * 2) 41 | this.messageSize = UIKit.getContentSize( 42 | this.messageFont, 43 | this.message, 44 | this.boxWidth - this.textHorizontalMargin * 2 45 | ) 46 | } 47 | 48 | textView() { 49 | return { 50 | type: "view", 51 | views: [ 52 | { 53 | type: "label", 54 | props: { 55 | lines: 0, 56 | font: this.titleFont, 57 | text: this.title, 58 | color: $color("tint"), 59 | align: $align.center 60 | }, 61 | layout: (make, view) => { 62 | make.centerX.equalTo(view.super) 63 | make.width.equalTo(this.boxWidth - this.textHorizontalMargin * 2) 64 | make.height.equalTo(this.titleSize.height) 65 | make.top.equalTo(this.textVerticalMargin) 66 | } 67 | }, 68 | { 69 | type: "label", 70 | props: { 71 | lines: 0, 72 | font: this.messageFont, 73 | text: this.message, 74 | align: $align.center 75 | }, 76 | layout: (make, view) => { 77 | make.centerX.equalTo(view.super) 78 | make.width.equalTo(this.boxWidth - this.textHorizontalMargin * 2) 79 | make.height.equalTo(this.messageSize.height) 80 | make.top.equalTo(view.prev.bottom) 81 | } 82 | } 83 | ], 84 | layout: (make, view) => { 85 | make.top.width.equalTo(view.super) 86 | make.height.equalTo(this.titleSize.height + this.messageSize.height + this.textVerticalMargin * 2) 87 | } 88 | } 89 | } 90 | 91 | actionView() { 92 | /** 93 | * 94 | * @param {AlertAction} action 95 | * @param {number} i 96 | * @returns 97 | */ 98 | const buttonView = (action, i) => { 99 | const touchInBox = location => { 100 | if (this.actions.length === 2) { 101 | return ( 102 | location.y >= 0 && 103 | location.x >= 0 && 104 | location.y <= this.actionButtonHeight && 105 | location.x <= this.boxWidth / 2 106 | ) 107 | } else { 108 | return ( 109 | location.y >= 0 && 110 | location.x >= 0 && 111 | location.y <= this.actionButtonHeight && 112 | location.x <= this.boxWidth 113 | ) 114 | } 115 | } 116 | const touchesEndedAndCancelled = async (sender, location, locations) => { 117 | if (action.disabled) return 118 | sender.bgcolor = $color("clear") 119 | // 判断是否响应点击事件 120 | if (touchInBox(location)) { 121 | if (typeof action.handler === "function") { 122 | await action.handler({ index: i, title: action.title }) 123 | } 124 | this.dismiss() 125 | } 126 | } 127 | let color = $color("tint") 128 | let font = $font(this.actionButtonFontSize) 129 | if (action.disabled) { 130 | color = $color("gray") 131 | } else { 132 | if (action.style === $alertActionType.destructive) { 133 | color = $color("red") 134 | } else if (action.style === $alertActionType.cancel) { 135 | font = $font("bold", this.actionButtonFontSize) 136 | } 137 | } 138 | return { 139 | type: "label", 140 | props: { 141 | lines: 1, 142 | text: action.title, 143 | align: $align.center, 144 | font: font, 145 | color: color, 146 | borderWidth: this.actionButtonBoderWidth, 147 | borderColor: this.actionButtonBorderColor, 148 | bgcolor: $color("clear") 149 | }, 150 | events: { 151 | tapped: () => {}, 152 | touchesBegan: sender => { 153 | if (action.disabled) return 154 | sender.bgcolor = this.actionButtonHighlight 155 | }, 156 | touchesEnded: touchesEndedAndCancelled, 157 | touchesCancelled: touchesEndedAndCancelled, 158 | touchesMoved: (sender, location, locations) => { 159 | if (action.disabled) return 160 | if (touchInBox(location)) { 161 | sender.bgcolor = this.actionButtonHighlight 162 | } else { 163 | sender.bgcolor = $color("clear") 164 | } 165 | } 166 | }, 167 | layout: (make, view) => { 168 | if (this.actions.length === 2) { 169 | if (view.prev) { 170 | make.left.equalTo(view.prev.right).offset(-this.actionButtonBoderWidth) 171 | make.width.equalTo(view.super).dividedBy(2).offset(this.actionButtonBoderWidth) 172 | } else { 173 | make.width.equalTo(view.super).dividedBy(2) 174 | } 175 | } else { 176 | make.width.equalTo(view.super) 177 | if (view.prev) { 178 | make.top.equalTo(view.prev.bottom).offset(-this.actionButtonBoderWidth) 179 | } 180 | } 181 | make.height.equalTo(this.actionButtonHeight) 182 | } 183 | } 184 | } 185 | return { 186 | type: "view", 187 | views: this.actions.map((action, i) => buttonView(action, i)), 188 | layout: (make, view) => { 189 | make.left.equalTo(view.super).offset(-this.actionButtonBoderWidth) 190 | make.width.equalTo(view.super).offset(this.actionButtonBoderWidth * 2) 191 | make.bottom.equalTo(view.super) 192 | make.top.equalTo(view.prev.bottom) 193 | } 194 | } 195 | } 196 | 197 | getView() { 198 | const boxView = { 199 | type: "view", 200 | props: { 201 | id: this.id + "-box", 202 | smoothCorners: true, 203 | cornerRadius: 20, 204 | bgcolor: $color("#FFFFFF", "#000000") 205 | }, 206 | views: [this.textView(), this.actionView()], 207 | layout: (make, view) => { 208 | make.center.equalTo(view.super.safeArea) 209 | 210 | let height = this.titleSize.height + this.messageSize.height + this.textVerticalMargin * 2 211 | let length = this.actions.length > 2 ? this.actions.length : 1 212 | height += this.actionButtonHeight * length 213 | height -= this.actionButtonBoderWidth * length 214 | let width = this.boxWidth - this.actionButtonBoderWidth * 2 215 | make.size.equalTo($size(width, height)) 216 | } 217 | } 218 | return { 219 | type: "view", 220 | props: { 221 | id: this.id, 222 | alpha: 0, 223 | bgcolor: $rgba(0, 0, 0, 0.6) 224 | }, 225 | views: [boxView], 226 | layout: $layout.fill 227 | } 228 | } 229 | 230 | /** 231 | * 弹出 Alert 232 | */ 233 | present() { 234 | const view = $ui.create(this.getView()) 235 | if ($ui.controller.view.hidden) { 236 | $ui.controller.view.super.insertAtIndex(view, 0) 237 | } else { 238 | $ui.controller.view.insertAtIndex(view, 0) 239 | } 240 | const alert = $(this.id) 241 | alert.layout($layout.fill) 242 | alert.moveToFront() 243 | $ui.animate({ 244 | duration: 0.3, 245 | animation: () => { 246 | alert.alpha = 1 247 | } 248 | }) 249 | } 250 | 251 | /** 252 | * 关闭 Alert 253 | */ 254 | dismiss() { 255 | const alert = $(this.id) 256 | $ui.animate({ 257 | duration: 0.3, 258 | animation: () => { 259 | alert.alpha = 0 260 | }, 261 | completion: () => { 262 | alert.remove() 263 | } 264 | }) 265 | } 266 | 267 | static fromJsbox(object) { 268 | return new Promise((resolve, reject) => { 269 | const alert = new Alert({ 270 | title: object.title, 271 | message: object.message, 272 | actions: object?.actions?.map(action => { 273 | if (!action.handler) { 274 | action.handler = res => { 275 | resolve(res) 276 | } 277 | } 278 | return new AlertAction(action) 279 | }) 280 | }) 281 | alert.present() 282 | }) 283 | } 284 | } 285 | 286 | module.exports = { 287 | Alert, 288 | AlertAction 289 | } 290 | -------------------------------------------------------------------------------- /src/navigation-view/navigation-view.js: -------------------------------------------------------------------------------- 1 | const { Controller } = require("../controller") 2 | const { View, PageView } = require("../view") 3 | const { ValidationError } = require("../validation-error") 4 | const { Kernel } = require("../kernel") 5 | const { UIKit } = require("../ui-kit") 6 | 7 | const { NavigationBar, NavigationBarController } = require("./navigation-bar") 8 | const { NavigationBarItems } = require("./navigation-bar-items") 9 | 10 | class NavigationViewTypeError extends ValidationError { 11 | constructor(parameter, type) { 12 | super(parameter, type) 13 | this.name = "NavigationViewTypeError" 14 | } 15 | } 16 | 17 | /** 18 | * @typedef {NavigationView} NavigationView 19 | */ 20 | class NavigationView extends Controller { 21 | /** 22 | * @type {PageView} 23 | */ 24 | page 25 | 26 | navigationController = new NavigationBarController() 27 | navigationBar = new NavigationBar() 28 | navigationBarItems = new NavigationBarItems() 29 | 30 | constructor() { 31 | super() 32 | this.navigationBar.navigationBarItems = this.navigationBarItems 33 | this.navigationController.navigationBar = this.navigationBar 34 | } 35 | 36 | navigationBarTitle(title) { 37 | this.navigationBar.setTitle(title) 38 | return this 39 | } 40 | 41 | /** 42 | * 43 | * @param {object} view 44 | * @returns {this} 45 | */ 46 | setView(view) { 47 | if (typeof view !== "object") { 48 | throw new NavigationViewTypeError("view", "object") 49 | } 50 | this.view = View.create(view) 51 | return this 52 | } 53 | 54 | getTopOffset() { 55 | if (!(this.view instanceof View)) { 56 | throw new NavigationViewTypeError("view", "View") 57 | } 58 | 59 | const scrollView = this.view.scrollableView 60 | const topSafeAreaInsets = $app.isDebugging ? 0 : UIKit.topSafeAreaInsets 61 | const navigationBarHeight = 62 | this.navigationBar.largeTitleDisplayMode === NavigationBar.largeTitleDisplayModeNever 63 | ? this.navigationBar.navigationBarNormalHeight 64 | : this.navigationBar.navigationBarLargeTitleHeight 65 | 66 | // 计算偏移高度 67 | let height = this.navigationBar.contentViewHeightOffset + navigationBarHeight 68 | if (this.navigationBarItems.titleView) { 69 | height += this.navigationBarItems.titleView.topOffset 70 | height += this.navigationBarItems.titleView.height 71 | height += this.navigationBarItems.titleView.bottomOffset 72 | } 73 | 74 | // 非滚动视图 75 | // 对于子视图可滚动的项目,必须手动指定滚动视图 props.associateWithNavigationBar 为 false 才会跳过绑定 76 | if (!this.view.scrollable || scrollView.props.associateWithNavigationBar === false) { 77 | let topOffset = height - this.navigationBar.contentViewHeightOffset 78 | if ((!UIKit.isHorizontal || UIKit.isLargeScreen) && this.navigationBar.topSafeArea) { 79 | topOffset += topSafeAreaInsets 80 | } 81 | return topOffset 82 | } 83 | 84 | if (scrollView.props.stickyHeader) { 85 | height -= navigationBarHeight 86 | height += this.navigationBar.largeTitleFontHeight 87 | } 88 | return height 89 | } 90 | 91 | getBottomOffset() { 92 | return this.navigationBarItems.fixedFooterView?.height ?? 0 93 | } 94 | 95 | #bindScrollEvents() { 96 | if (!(this.view instanceof View)) { 97 | throw new NavigationViewTypeError("view", "View") 98 | } 99 | 100 | const scrollView = this.view.scrollableView 101 | const topSafeAreaInsets = $app.isDebugging ? 0 : UIKit.topSafeAreaInsets 102 | 103 | // 计算偏移高度 104 | const height = this.getTopOffset() 105 | 106 | // 非滚动视图 107 | // 对于子视图可滚动的项目,必须手动指定滚动视图 props.associateWithNavigationBar 为 false 才会跳过绑定 108 | if (!this.view.scrollable || scrollView.props.associateWithNavigationBar === false) { 109 | this.view.layout = (make, view) => { 110 | make.left.right.equalTo(view.super.safeArea) 111 | make.bottom.equalTo(view.super) 112 | make.top.equalTo(height) 113 | } 114 | return 115 | } 116 | 117 | // indicatorInsets 118 | const pinTitleViewOffset = this.navigationBarItems.isPinTitleView 119 | ? this.navigationBarItems.titleView.height + 120 | this.navigationBarItems.titleView.bottomOffset + 121 | this.navigationBar.contentViewHeightOffset 122 | : 0 123 | if (scrollView.props.indicatorInsets) { 124 | const old = scrollView.props.indicatorInsets 125 | scrollView.props.indicatorInsets = $insets( 126 | old.top + this.navigationBar.navigationBarNormalHeight + pinTitleViewOffset, 127 | old.left, 128 | old.bottom + this.getBottomOffset(), 129 | old.right 130 | ) 131 | } else { 132 | scrollView.props.indicatorInsets = $insets( 133 | this.navigationBar.navigationBarNormalHeight + pinTitleViewOffset, 134 | 0, 135 | this.navigationBarItems.fixedFooterView?.height ?? 0, 136 | 0 137 | ) 138 | } 139 | 140 | // contentInset 141 | if (scrollView.props.contentInset) { 142 | const old = scrollView.props.contentInset 143 | scrollView.props.contentInset = $insets( 144 | old.top + height + pinTitleViewOffset, 145 | old.left, 146 | old.bottom + this.getBottomOffset(), 147 | old.right 148 | ) 149 | } else { 150 | scrollView.props.contentInset = $insets( 151 | height + pinTitleViewOffset, 152 | 0, 153 | this.navigationBarItems.fixedFooterView?.height ?? 0, 154 | 0 155 | ) 156 | } 157 | 158 | scrollView.props.contentOffset = $point(0, -height) 159 | 160 | // layout 161 | scrollView.layout = (make, view) => { 162 | if (scrollView.props.stickyHeader) { 163 | make.top.equalTo(view.super.safeArea).offset(this.navigationBar.navigationBarNormalHeight) 164 | } else { 165 | make.top.equalTo(view.super) 166 | } 167 | make.left.right.equalTo(view.super.safeArea) 168 | make.bottom.equalTo(view.super) 169 | } 170 | 171 | // 重写滚动事件 172 | scrollView 173 | .assignEvent("didScroll", sender => { 174 | let contentOffset = sender.contentOffset.y 175 | if ( 176 | (!UIKit.isHorizontal || UIKit.isLargeScreen) && 177 | this.navigationBar.topSafeArea && 178 | !scrollView.props.stickyHeader 179 | ) { 180 | contentOffset += topSafeAreaInsets 181 | } 182 | contentOffset += height 183 | this.navigationController.didScroll(contentOffset) 184 | }) 185 | .assignEvent("didEndDragging", (sender, decelerate) => { 186 | let contentOffset = sender.contentOffset.y 187 | let zeroOffset = 0 188 | if ( 189 | (!UIKit.isHorizontal || UIKit.isLargeScreen) && 190 | this.navigationBar.topSafeArea && 191 | !scrollView.props.stickyHeader 192 | ) { 193 | contentOffset += topSafeAreaInsets 194 | zeroOffset = topSafeAreaInsets 195 | } 196 | contentOffset += height 197 | zeroOffset += height 198 | this.navigationController.didEndDragging( 199 | contentOffset, 200 | decelerate, 201 | (...args) => sender.scrollToOffset(...args), 202 | zeroOffset 203 | ) 204 | }) 205 | .assignEvent("didEndDecelerating", (...args) => { 206 | if (args[0].tracking) { 207 | return 208 | } 209 | scrollView.events?.didEndDragging(...args) 210 | }) 211 | } 212 | 213 | #initPage() { 214 | if (this.navigationBar.prefersLargeTitles) { 215 | this.#bindScrollEvents() 216 | 217 | let titleView = {} 218 | if (this.navigationBarItems.titleView) { 219 | // 修改 titleView 背景与 navigationBar 相同 220 | const isHideBackground = 221 | this.navigationBar.largeTitleDisplayMode === NavigationBar.largeTitleDisplayModeNever ? 1 : 0 222 | titleView = View.create({ 223 | views: [ 224 | this.navigationBar.backgroundColor 225 | ? { 226 | type: "view", 227 | props: { 228 | alpha: isHideBackground, 229 | bgcolor: this.navigationBar.backgroundColor, 230 | id: this.navigationBar.id + "-title-view-background" 231 | }, 232 | layout: $layout.fill 233 | } 234 | : UIKit.blurBox({ 235 | alpha: isHideBackground, 236 | id: this.navigationBar.id + "-title-view-background" 237 | }), 238 | UIKit.separatorLine({ 239 | id: this.navigationBar.id + "-title-view-underline", 240 | alpha: isHideBackground 241 | }), 242 | this.navigationBarItems.titleView.definition 243 | ], 244 | layout: (make, view) => { 245 | make.top.equalTo(view.prev.bottom) 246 | make.width.equalTo(view.super) 247 | make.height.equalTo( 248 | this.navigationBarItems.titleView.topOffset + 249 | this.navigationBarItems.titleView.height + 250 | this.navigationBarItems.titleView.bottomOffset 251 | ) 252 | } 253 | }) 254 | } 255 | 256 | // 初始化 PageView 257 | this.page = PageView.createFromViews([ 258 | this.view, 259 | this.navigationBar.getLargeTitleView(), 260 | titleView, 261 | this.navigationBar.getNavigationBarView(), 262 | this.navigationBarItems.fixedFooterView?.definition ?? {} 263 | ]) 264 | } else { 265 | this.page = PageView.createFromViews([this.view]) 266 | } 267 | if (this.view.props?.bgcolor) { 268 | this.page.setProp("bgcolor", this.view.props.bgcolor) 269 | } else { 270 | this.page.setProp("bgcolor", UIKit.defaultBackgroundColor(this.view.type)) 271 | } 272 | } 273 | 274 | getPage() { 275 | if (!this.page) { 276 | this.#initPage() 277 | } 278 | return this.page 279 | } 280 | } 281 | 282 | module.exports = { 283 | NavigationView 284 | } 285 | -------------------------------------------------------------------------------- /src/tab-bar.js: -------------------------------------------------------------------------------- 1 | const { View, PageView } = require("./view") 2 | const { Controller } = require("./controller") 3 | const { UIKit } = require("./ui-kit") 4 | 5 | class TabBarCellView extends View { 6 | constructor(args = {}) { 7 | super(args) 8 | this.setIcon(args.icon) 9 | this.setTitle(args.title) 10 | if (args.activeStatus !== undefined) { 11 | this.activeStatus = args.activeStatus 12 | } 13 | } 14 | 15 | setIcon(icon) { 16 | // 格式化单个icon和多个icon 17 | if (icon instanceof Array) { 18 | this.icon = icon 19 | } else { 20 | this.icon = [icon, icon] 21 | } 22 | return this 23 | } 24 | 25 | setTitle(title) { 26 | this.title = title 27 | return this 28 | } 29 | 30 | active() { 31 | $(`${this.props.id}-icon`).image = $image(this.icon[1]) 32 | $(`${this.props.id}-icon`).tintColor = $color("systemLink") 33 | $(`${this.props.id}-title`).textColor = $color("systemLink") 34 | this.activeStatus = true 35 | } 36 | 37 | inactive() { 38 | $(`${this.props.id}-icon`).image = $image(this.icon[0]) 39 | $(`${this.props.id}-icon`).tintColor = $color("lightGray") 40 | $(`${this.props.id}-title`).textColor = $color("lightGray") 41 | this.activeStatus = false 42 | } 43 | 44 | getView() { 45 | this.views = [ 46 | { 47 | type: "image", 48 | props: { 49 | id: `${this.props.id}-icon`, 50 | image: $image(this.activeStatus ? this.icon[1] : this.icon[0]), 51 | bgcolor: $color("clear"), 52 | tintColor: $color(this.activeStatus ? "systemLink" : "lightGray") 53 | }, 54 | layout: (make, view) => { 55 | make.centerX.equalTo(view.super) 56 | const half = TabBarController.tabBarHeight / 2 57 | make.size.equalTo(half) 58 | make.top.inset((TabBarController.tabBarHeight - half - 13) / 2) 59 | } 60 | }, 61 | { 62 | type: "label", 63 | props: { 64 | id: `${this.props.id}-title`, 65 | text: this.title, 66 | font: $font(10), 67 | textColor: $color(this.activeStatus ? "systemLink" : "lightGray") 68 | }, 69 | layout: (make, view) => { 70 | make.centerX.equalTo(view.prev) 71 | make.top.equalTo(view.prev.bottom).offset(3) 72 | } 73 | } 74 | ] 75 | return this 76 | } 77 | } 78 | 79 | class TabBarHeaderView extends View { 80 | height = 60 81 | 82 | getView() { 83 | this.type = "view" 84 | this.setProp("bgcolor", this.props.bgcolor ?? UIKit.primaryViewBackgroundColor) 85 | this.layout = (make, view) => { 86 | make.left.right.bottom.equalTo(view.super) 87 | make.top.equalTo(view.super.safeAreaBottom).offset(-this.height - TabBarController.tabBarHeight) 88 | } 89 | 90 | this.views = [ 91 | View.create({ 92 | props: this.props, 93 | views: this.views, 94 | layout: (make, view) => { 95 | make.left.right.top.equalTo(view.super) 96 | make.height.equalTo(this.height) 97 | } 98 | }) 99 | ] 100 | 101 | return this 102 | } 103 | } 104 | 105 | /** 106 | * @property {function(from: string, to: string)} TabBarController.events.onChange 107 | */ 108 | class TabBarController extends Controller { 109 | static tabBarHeight = 50 110 | 111 | #pages = {} 112 | #cells = {} 113 | #header 114 | #selected 115 | #blurBoxId = $text.uuid 116 | #separatorLineId = $text.uuid 117 | bottomSafeAreaInsets = $app.isDebugging ? 0 : UIKit.bottomSafeAreaInsets 118 | 119 | get selected() { 120 | return this.#selected 121 | } 122 | 123 | set selected(selected) { 124 | this.switchPageTo(selected) 125 | } 126 | 127 | get contentOffset() { 128 | return TabBarController.tabBarHeight + (this.#header?.height ?? 0) 129 | } 130 | 131 | /** 132 | * 133 | * @param {object} pages 134 | * @returns {this} 135 | */ 136 | setPages(pages = {}) { 137 | Object.keys(pages).forEach(key => this.setPage(key, pages[key])) 138 | return this 139 | } 140 | 141 | setPage(key, page) { 142 | if (this.#selected === undefined) this.#selected = key 143 | if (page instanceof PageView) { 144 | this.#pages[key] = page 145 | } else { 146 | this.#pages[key] = PageView.create(page) 147 | } 148 | if (this.#selected !== key) this.#pages[key].activeStatus = false 149 | return this 150 | } 151 | 152 | switchPageTo(key) { 153 | if (this.#pages[key]) { 154 | if (this.#selected === key) return 155 | // menu 动画 156 | $ui.animate({ 157 | duration: 0.4, 158 | animation: () => { 159 | // 点击的图标 160 | this.#cells[key].active() 161 | } 162 | }) 163 | // 之前的图标 164 | this.#cells[this.#selected].inactive() 165 | // 切换页面 166 | this.#pages[this.#selected].hide() 167 | this.#pages[key].show() 168 | this.callEvent("onChange", this.#selected, key) 169 | this.#selected = key 170 | // 调整背景 171 | this.initBackground() 172 | } 173 | } 174 | 175 | hideBackground(animate = true) { 176 | $(this.#separatorLineId).hidden = true 177 | $ui.animate({ 178 | duration: animate ? 0.2 : 0.0001, 179 | animation: () => { 180 | $(this.#blurBoxId).alpha = 0 181 | } 182 | }) 183 | } 184 | 185 | showBackground(animate = true) { 186 | $(this.#separatorLineId).hidden = false 187 | $ui.animate({ 188 | duration: animate ? 0.2 : 0.0001, 189 | animation: () => { 190 | $(this.#blurBoxId).alpha = 1 191 | } 192 | }) 193 | } 194 | 195 | initBackground() { 196 | const selectedPage = this.#pages[this.selected] 197 | if (selectedPage.scrollable) { 198 | $delay(0, () => { 199 | const scrollableView = $(selectedPage.id).get(selectedPage.scrollableView.id) 200 | 201 | const contentOffset = scrollableView.contentOffset.y 202 | const contentSize = scrollableView.contentSize.height + this.bottomSafeAreaInsets 203 | const nextSize = contentSize - scrollableView.frame.height 204 | if (nextSize - contentOffset <= 0) { 205 | this.hideBackground(false) 206 | } else { 207 | this.showBackground(false) 208 | } 209 | }) 210 | } 211 | } 212 | 213 | /** 214 | * 215 | * @param {object} cells 216 | * @returns {this} 217 | */ 218 | setCells(cells = {}) { 219 | Object.keys(cells).forEach(key => this.setCell(key, cells[key])) 220 | return this 221 | } 222 | 223 | setCell(key, cell) { 224 | if (this.#selected === undefined) this.#selected = key 225 | if (!(cell instanceof TabBarCellView)) { 226 | cell = new TabBarCellView({ 227 | props: { info: { key } }, 228 | icon: cell.icon, 229 | title: cell.title, 230 | activeStatus: this.#selected === key 231 | }) 232 | } 233 | this.#cells[key] = cell 234 | return this 235 | } 236 | 237 | setHeader(view) { 238 | this.#header = view 239 | return this 240 | } 241 | 242 | #cellViews() { 243 | const views = [] 244 | Object.values(this.#cells).forEach(cell => { 245 | cell.setEvent("tapped", sender => { 246 | const key = sender.info.key 247 | // 切换页面 248 | this.switchPageTo(key) 249 | }) 250 | views.push(cell.getView()) 251 | }) 252 | return views 253 | } 254 | 255 | #pageViews() { 256 | return Object.values(this.#pages).map(page => { 257 | if (page.scrollable) { 258 | const scrollView = page.scrollableView 259 | 260 | // indicatorInsets 261 | if (scrollView.props.indicatorInsets) { 262 | const old = scrollView.props.indicatorInsets 263 | scrollView.props.indicatorInsets = $insets( 264 | old.top, 265 | old.left, 266 | old.bottom + this.contentOffset, 267 | old.right 268 | ) 269 | } else { 270 | scrollView.props.indicatorInsets = $insets(0, 0, this.contentOffset, 0) 271 | } 272 | 273 | // contentInset 274 | if (scrollView.props.contentInset) { 275 | const old = scrollView.props.contentInset 276 | scrollView.props.contentInset = $insets( 277 | old.top, 278 | old.left, 279 | old.bottom + this.contentOffset, 280 | old.right 281 | ) 282 | } else { 283 | scrollView.props.contentInset = $insets(0, 0, this.contentOffset, 0) 284 | } 285 | 286 | // Scroll 287 | if (typeof scrollView.assignEvent === "function") { 288 | scrollView.assignEvent("didScroll", sender => { 289 | const contentOffset = sender.contentOffset.y - this.contentOffset 290 | const contentSize = sender.contentSize.height + this.bottomSafeAreaInsets 291 | const nextSize = contentSize - sender.frame.height 292 | if (nextSize - contentOffset <= 1) { 293 | this.hideBackground() 294 | } else { 295 | this.showBackground() 296 | } 297 | }) 298 | } 299 | } 300 | return page.definition 301 | }) 302 | } 303 | 304 | generateView() { 305 | const tabBarView = { 306 | type: "view", 307 | layout: (make, view) => { 308 | make.centerX.equalTo(view.super) 309 | make.width.equalTo(view.super) 310 | make.top.equalTo(view.super.safeAreaBottom).offset(-TabBarController.tabBarHeight) 311 | make.bottom.equalTo(view.super) 312 | }, 313 | views: [ 314 | UIKit.blurBox({ id: this.#blurBoxId }), 315 | { 316 | type: "stack", 317 | layout: $layout.fillSafeArea, 318 | props: { 319 | axis: $stackViewAxis.horizontal, 320 | distribution: $stackViewDistribution.fillEqually, 321 | spacing: 0, 322 | stack: { 323 | views: this.#cellViews() 324 | } 325 | } 326 | }, 327 | UIKit.separatorLine({ id: this.#separatorLineId }, UIKit.align.top) 328 | ], 329 | events: { 330 | ready: () => this.initBackground() 331 | } 332 | } 333 | return View.createFromViews(this.#pageViews().concat(this.#header?.definition ?? [], tabBarView)) 334 | } 335 | } 336 | 337 | module.exports = { 338 | TabBarCellView, 339 | TabBarHeaderView, 340 | TabBarController 341 | } 342 | -------------------------------------------------------------------------------- /src/navigation-view/navigation-bar-items.js: -------------------------------------------------------------------------------- 1 | const { View } = require("../view") 2 | const { UIKit } = require("../ui-kit") 3 | 4 | class BarTitleView extends View { 5 | controller = {} 6 | 7 | setController(controller) { 8 | this.controller = controller 9 | return this 10 | } 11 | } 12 | 13 | /** 14 | * 用于创建一个靠右侧按钮(自动布局) 15 | * this.events.tapped 按钮点击事件,会传入三个函数,start()、done() 和 cancel() 16 | * 调用 start() 表明按钮被点击,准备开始动画 17 | * 调用 done() 表明您的操作已经全部完成,默认操作成功完成,播放一个按钮变成对号的动画 18 | * 若第一个参数传出false则表示运行出错 19 | * 第二个参数为错误原因($ui.toast(message)) 20 | * 调用 cancel() 表示取消操作 21 | * 示例: 22 | * (start, done, cancel) => { 23 | * start() 24 | * const upload = (data) => { return false } 25 | * if (upload(data)) { done() } 26 | * else { done(false, "Upload Error!") } 27 | * } 28 | */ 29 | 30 | /** 31 | * @typedef {object} BarButtonItemProperties 32 | * @property {string} title 33 | * @property {string} symbol 34 | * @property {Function} tapped 35 | * @property {object} menu 36 | * @property {object} events 37 | */ 38 | 39 | class BarButtonItem extends View { 40 | static #instance 41 | 42 | edges = 15 43 | buttonEdges = this.edges / 2 44 | iconSize = 24 45 | fontSize = 17 46 | 47 | color = UIKit.textColor 48 | 49 | /** 50 | * 标题 51 | * @type {string} 52 | */ 53 | title 54 | 55 | /** 56 | * SF Symbol 或者 $image 对象 57 | * @type {string|$image} 58 | */ 59 | #symbol 60 | #symbolType 61 | 62 | /** 63 | * 对齐方式 64 | */ 65 | align = UIKit.align.right 66 | 67 | get symbol() { 68 | if (typeof this.#symbol === "string") { 69 | if (this.#symbolType === "icon") { 70 | return $icon(this.#symbol, this.color).ocValue().$image().jsValue() 71 | } else { 72 | return $image(this.#symbol) 73 | } 74 | } else { 75 | return this.#symbol 76 | } 77 | } 78 | 79 | set symbol(symbol) { 80 | if (typeof this.#symbol === "string") { 81 | if (isNaN(symbol)) { 82 | this.#symbolType = "image" 83 | } else { 84 | this.#symbolType = "icon" 85 | } 86 | } else { 87 | if (String(symbol) === "[object BBFileIcon]") { 88 | this.#symbolType = "icon" 89 | } else { 90 | this.#symbolType = "image" 91 | } 92 | } 93 | this.#symbol = symbol 94 | } 95 | 96 | get width() { 97 | if (this.title) { 98 | const fontSize = $text.sizeThatFits({ 99 | text: this.title, 100 | width: UIKit.windowSize.width, 101 | font: $font(this.fontSize) 102 | }) 103 | return Math.ceil(fontSize.width) + this.edges // 文本按钮增加内边距 104 | } 105 | 106 | return this.iconSize + this.edges 107 | } 108 | 109 | static get style() { 110 | if (this.#instance === undefined) { 111 | this.#instance = new BarButtonItem() 112 | } 113 | 114 | return this.#instance 115 | } 116 | 117 | setEdges(edges) { 118 | this.edges = edges 119 | return this 120 | } 121 | 122 | setFontSize(fontSize) { 123 | this.fontSize = fontSize 124 | if ($(this.id)) { 125 | $(this.id).font = $font(this.fontSize) 126 | } 127 | return this 128 | } 129 | 130 | setColor(color = UIKit.textColor) { 131 | this.color = color 132 | if ($(this.id)) { 133 | $(this.id).titleColor = this.color 134 | $(`icon-button-${this.id}`).titleColor = this.color 135 | $(`icon-checkmark-${this.id}`).titleColor = this.color 136 | } 137 | return this 138 | } 139 | 140 | setTitle(title) { 141 | this.title = title 142 | if ($(this.id)) { 143 | $(this.id).title = this.title 144 | } 145 | return this 146 | } 147 | 148 | /** 149 | * 设置图标 150 | * @param {string|$image} symbol SF Symbol 或者 $image 对象 151 | * @returns 152 | */ 153 | setSymbol(symbol) { 154 | this.symbol = symbol 155 | if ($(`icon-button-${this.id}`)) { 156 | $(`icon-button-${this.id}`).image = this.symbol 157 | } 158 | return this 159 | } 160 | 161 | setMenu(menu) { 162 | this.menu = menu 163 | return this 164 | } 165 | 166 | setAlign(align) { 167 | this.align = align 168 | return this 169 | } 170 | 171 | setLoading(loading) { 172 | if (loading) { 173 | // 隐藏button,显示spinner 174 | $(this.id).hidden = true 175 | $("spinner-" + this.id).hidden = false 176 | } else { 177 | $(this.id).hidden = false 178 | $("spinner-" + this.id).hidden = true 179 | } 180 | } 181 | 182 | #checkMark() { 183 | const buttonIcon = $(`icon-button-${this.id}`) 184 | const checkmarkIcon = $(`icon-checkmark-${this.id}`) 185 | buttonIcon.alpha = 0 186 | $(this.id).hidden = false 187 | $("spinner-" + this.id).hidden = true 188 | // 成功动画 189 | $ui.animate({ 190 | duration: 0.6, 191 | animation: () => { 192 | checkmarkIcon.alpha = 1 193 | }, 194 | completion: () => { 195 | $delay(0.3, () => 196 | $ui.animate({ 197 | duration: 0.6, 198 | animation: () => { 199 | checkmarkIcon.alpha = 0 200 | }, 201 | completion: () => { 202 | $ui.animate({ 203 | duration: 0.4, 204 | animation: () => { 205 | buttonIcon.alpha = 1 206 | }, 207 | completion: () => { 208 | buttonIcon.alpha = 1 209 | } 210 | }) 211 | } 212 | }) 213 | ) 214 | } 215 | }) 216 | } 217 | 218 | hide() { 219 | $(this.id + "-container").hidden = true 220 | } 221 | show() { 222 | $(this.id + "-container").hidden = false 223 | } 224 | 225 | getView() { 226 | const userTapped = this.events.tapped 227 | this.events.tapped = sender => { 228 | if (!userTapped) return 229 | userTapped( 230 | { 231 | start: () => this.setLoading(true), 232 | done: () => this.#checkMark(), 233 | cancel: () => this.setLoading(false) 234 | }, 235 | sender 236 | ) 237 | } 238 | 239 | return { 240 | type: "view", 241 | props: { 242 | id: this.id + "-container", 243 | info: { align: this.align } 244 | }, 245 | views: [ 246 | { 247 | type: "button", 248 | props: Object.assign( 249 | { 250 | id: this.id, 251 | bgcolor: $color("clear"), 252 | font: $font(this.fontSize), 253 | titleColor: this.color, 254 | contentEdgeInsets: $insets(0, 0, 0, 0), 255 | titleEdgeInsets: $insets(0, 0, 0, 0), 256 | imageEdgeInsets: $insets(0, 0, 0, 0) 257 | }, 258 | this.menu ? { menu: this.menu } : {}, 259 | this.title ? { title: this.title } : {}, 260 | this.props 261 | ), 262 | views: [ 263 | { 264 | type: "image", 265 | props: Object.assign( 266 | { 267 | id: `icon-button-${this.id}`, 268 | hidden: this.symbol === undefined, 269 | tintColor: this.color 270 | }, 271 | this.symbol ? { image: this.symbol } : {} 272 | ), 273 | layout: (make, view) => { 274 | if (this.symbol) { 275 | make.size.equalTo(UIKit.getSymbolSize(this.symbol, this.iconSize)) 276 | } 277 | make.center.equalTo(view.super) 278 | } 279 | }, 280 | { 281 | type: "image", 282 | props: { 283 | id: `icon-checkmark-${this.id}`, 284 | alpha: 0, 285 | tintColor: this.color, 286 | symbol: "checkmark" 287 | }, 288 | layout: (make, view) => { 289 | make.center.equalTo(view.super) 290 | make.size.equalTo(UIKit.getSymbolSize("checkmark", this.iconSize)) 291 | } 292 | } 293 | ], 294 | events: this.events, 295 | layout: $layout.fill 296 | }, 297 | { 298 | type: "spinner", 299 | props: { 300 | id: "spinner-" + this.id, 301 | loading: true, 302 | hidden: true 303 | }, 304 | layout: $layout.fill 305 | } 306 | ], 307 | layout: (make, view) => { 308 | make.size.equalTo($size(this.width, UIKit.NavigationBarNormalHeight)) 309 | make.centerY.equalTo(view.super) 310 | if (view.prev && view.prev?.info?.align === this.align) { 311 | if (this.align === UIKit.align.right) make.right.equalTo(view.prev.left).offset(-this.buttonEdges) 312 | else make.left.equalTo(view.prev.right).offset(this.buttonEdges) 313 | } else { 314 | // 留一半边距,按钮内边距是另一半 315 | const edges = this.edges / 2 316 | if (this.align === UIKit.align.right) make.right.inset(edges) 317 | else make.left.inset(edges) 318 | } 319 | } 320 | } 321 | } 322 | 323 | /** 324 | * 用于快速创建 BarButtonItem 325 | * @param {BarButtonItemProperties} param0 326 | * @returns {BarButtonItem} 327 | */ 328 | static creat({ id, symbol, title, tapped, menu, events, color, align = UIKit.align.right } = {}) { 329 | const barButtonItem = new BarButtonItem() 330 | barButtonItem 331 | .setEvents(Object.assign({ tapped: tapped }, events)) 332 | .setAlign(align) 333 | .setSymbol(symbol) 334 | .setTitle(title) 335 | .setColor(color) 336 | .setMenu(menu) 337 | if (id) { 338 | barButtonItem.setProp("id", id) 339 | } 340 | return barButtonItem 341 | } 342 | } 343 | 344 | /** 345 | * @typedef {NavigationBarItems} NavigationBarItems 346 | */ 347 | class NavigationBarItems { 348 | rightButtons = [] 349 | leftButtons = [] 350 | #buttonIndex = {} 351 | hasbutton = false 352 | 353 | isPinTitleView = false 354 | 355 | setTitleView(titleView) { 356 | this.titleView = titleView 357 | return this 358 | } 359 | 360 | pinTitleView() { 361 | this.isPinTitleView = true 362 | return this 363 | } 364 | 365 | setFixedFooterView(fixedFooterView) { 366 | this.fixedFooterView = fixedFooterView 367 | return this 368 | } 369 | 370 | /** 371 | * 372 | * @param {BarButtonItemProperties[]} buttons 373 | * @returns {this} 374 | */ 375 | setRightButtons(buttons) { 376 | buttons.forEach(button => this.addRightButton(button)) 377 | if (!this.hasbutton) this.hasbutton = true 378 | return this 379 | } 380 | 381 | /** 382 | * 383 | * @param {BarButtonItemProperties[]} buttons 384 | * @returns {this} 385 | */ 386 | setLeftButtons(buttons) { 387 | buttons.forEach(button => this.addLeftButton(button)) 388 | if (!this.hasbutton) this.hasbutton = true 389 | return this 390 | } 391 | 392 | /** 393 | * 394 | * @param {BarButtonItemProperties} param0 395 | * @returns {this} 396 | */ 397 | addRightButton({ id, symbol, title, tapped, menu, events, color } = {}) { 398 | const button = BarButtonItem.creat({ id, symbol, title, tapped, menu, events, color, align: UIKit.align.right }) 399 | this.rightButtons.push(button) 400 | this.#buttonIndex[id ?? button.id] = button 401 | if (!this.hasbutton) this.hasbutton = true 402 | return this 403 | } 404 | 405 | /** 406 | * 407 | * @param {BarButtonItemProperties} param0 408 | * @returns {this} 409 | */ 410 | addLeftButton({ id, symbol, title, tapped, menu, events, color } = {}) { 411 | const button = BarButtonItem.creat({ id, symbol, title, tapped, menu, events, color, align: UIKit.align.left }) 412 | this.leftButtons.push(button) 413 | this.#buttonIndex[id ?? button.id] = button 414 | if (!this.hasbutton) this.hasbutton = true 415 | return this 416 | } 417 | 418 | getButton(id) { 419 | return this.#buttonIndex[id] 420 | } 421 | 422 | getButtons() { 423 | return Object.values(this.#buttonIndex) 424 | } 425 | 426 | /** 427 | * 覆盖左侧按钮 428 | * @param {string} parent 父页面标题,将会显示为文本按钮 429 | * @param {object} view 自定义按钮视图 430 | * @param {function} onPop 自定义按钮视图 431 | * @returns {this} 432 | */ 433 | addPopButton(parent, view, onPop) { 434 | if (!parent) { 435 | parent = $l10n("BACK") 436 | } 437 | this.popButtonView = view ?? { 438 | // 返回按钮 439 | type: "button", 440 | props: { 441 | bgcolor: $color("clear"), 442 | symbol: "chevron.left", 443 | tintColor: UIKit.linkColor, 444 | title: ` ${parent}`, 445 | titleColor: UIKit.linkColor, 446 | font: $font("bold", 16) 447 | }, 448 | layout: (make, view) => { 449 | make.left.equalTo(view.super.safeArea).offset(BarButtonItem.style.edges) 450 | make.centerY.equalTo(view.super.safeArea) 451 | }, 452 | events: { 453 | tapped: () => { 454 | $ui.pop() 455 | if (typeof onPop === "function") { 456 | onPop() 457 | } 458 | } 459 | } 460 | } 461 | return this 462 | } 463 | 464 | removePopButton() { 465 | this.popButtonView = undefined 466 | return this 467 | } 468 | } 469 | 470 | module.exports = { 471 | BarTitleView, 472 | BarButtonItem, 473 | NavigationBarItems 474 | } 475 | -------------------------------------------------------------------------------- /src/setting/setting.js: -------------------------------------------------------------------------------- 1 | const { Controller } = require("../controller") 2 | const { FileStorage } = require("../file-storage") 3 | const { Sheet } = require("../sheet") 4 | const { NavigationView } = require("../navigation-view/navigation-view") 5 | const { ViewController } = require("../navigation-view/view-controller") 6 | const { 7 | SettingItem, 8 | SettingInfo, 9 | SettingSwitch, 10 | SettingString, 11 | SettingStepper, 12 | SettingScript, 13 | SettingTab, 14 | SettingMenu, 15 | SettingColor, 16 | SettingDate, 17 | SettingInput, 18 | SettingNumber, 19 | SettingIcon, 20 | SettingPush, 21 | SettingChild, 22 | SettingImage 23 | } = require("./setting-items") 24 | const { Logger } = require("../logger") 25 | 26 | class SettingLoadConfigError extends Error { 27 | constructor() { 28 | super("Call loadConfig() first.") 29 | this.name = "SettingLoadConfigError" 30 | } 31 | } 32 | 33 | class SettingReadonlyError extends Error { 34 | constructor() { 35 | super("Attempted to assign to readonly property.") 36 | this.name = "SettingReadonlyError" 37 | } 38 | } 39 | 40 | /** 41 | * 脚本类型的动画 42 | * @typedef {object} ScriptAnimate 43 | * @property {Function} animate.start 44 | * @property {Function} animate.cancel 45 | * @property {Function} animate.done 46 | * @property {Function} animate.touchHighlightStart 47 | * @property {Function} animate.touchHighlightEnd 48 | * 49 | * 用于存放 script 类型用到的方法 50 | * @callback SettingMethodFunction 51 | * @param {ScriptAnimate} animate 52 | * 53 | * @typedef {object} SettingMethod 54 | * @property {SettingMethodFunction} * 55 | */ 56 | 57 | /** 58 | * @property {function(key: string, value: any)} Setting.events.onSet 键值发生改变 59 | * @property {function(view: Object,title: string)} Setting.events.onChildPush 进入的子页面 60 | */ 61 | class Setting extends Controller { 62 | name 63 | // 存储数据 64 | setting = {} 65 | settingItems = {} 66 | // 初始用户数据,若未定义则尝试从给定的文件读取 67 | userData 68 | // fileStorage 69 | fileStorage 70 | /** 71 | * @type {Logger} 72 | */ 73 | logger 74 | imagePath 75 | // 用来控制 child 类型 76 | viewController = new ViewController() 77 | /** 78 | * @type {SettingMethod} 79 | */ 80 | method = { 81 | readme: () => { 82 | const content = (() => { 83 | const file = $device.info?.language?.startsWith("zh") ? "README_CN.md" : "README.md" 84 | try { 85 | return __README__[file] ?? __README__["README.md"] 86 | } catch { 87 | return $file.read(file)?.string ?? $file.read("README.md")?.string 88 | } 89 | })() 90 | const sheet = new Sheet() 91 | sheet 92 | .setView({ 93 | type: "markdown", 94 | props: { content: content }, 95 | layout: (make, view) => { 96 | make.size.equalTo(view.super) 97 | } 98 | }) 99 | .addNavBar({ title: "README", popButton: { symbol: "x.circle" } }) 100 | .init() 101 | .present() 102 | } 103 | } 104 | // read only 105 | #readonly = false 106 | // 判断是否已经加载数据加载 107 | #loadConfigStatus = false 108 | #footer 109 | 110 | /** 111 | * 112 | * @param {object} args 113 | * @param {Function} args.set 自定义 set 方法,定义后将忽略 fileStorage 和 dataFile 114 | * @param {Function} args.get 自定义 get 方法,定义后将忽略 fileStorage 和 dataFile 115 | * @param {object} args.userData 初始用户数据,定义后将忽略 fileStorage 和 dataFile 116 | * @param {FileStorage} args.fileStorage FileStorage 对象,用于文件操作 117 | * @param {string} args.dataFile 持久化数据保存文件 118 | * @param {object} args.structure 设置项结构 119 | * @param {string} args.structurePath 结构路径,优先级低于 structure 120 | * @param {boolean} args.isUseJsboxNav 是否使用 JSBox 默认 nav 样式 121 | * @param {string} args.name 唯一名称,默认分配一个 UUID 122 | */ 123 | constructor(args = {}) { 124 | super() 125 | 126 | // set 和 get 同时设置才会生效 127 | if (typeof args.set === "function" && typeof args.get === "function") { 128 | this.set = args.set 129 | this.getOriginal = args.get 130 | this.setUserData(args.userData ?? {}) 131 | } else { 132 | this.fileStorage = args.fileStorage ?? new FileStorage() 133 | this.dataFile = args.dataFile ?? "setting.json" 134 | } 135 | if (args.structure) { 136 | this.setStructure(args.structure) // structure 优先级高于 structurePath 137 | } else { 138 | this.setStructurePath(args.structurePath ?? "setting.json") 139 | } 140 | this.logger = args.logger ?? new Logger() 141 | this.isUseJsboxNav = args.isUseJsboxNav ?? false 142 | // 不能使用 uuid 143 | this.imagePath = (args.name ?? "default") + ".image" + "/" 144 | this.setName(args.name ?? $text.uuid) 145 | } 146 | 147 | useJsboxNav() { 148 | this.isUseJsboxNav = true 149 | return this 150 | } 151 | 152 | #checkLoadConfig() { 153 | if (!this.#loadConfigStatus) { 154 | throw new SettingLoadConfigError() 155 | } 156 | } 157 | 158 | loader(item) { 159 | let settingItem = null 160 | switch (item.type) { 161 | case "info": 162 | settingItem = new SettingInfo(item) 163 | break 164 | case "switch": 165 | settingItem = new SettingSwitch(item) 166 | break 167 | case "string": 168 | settingItem = new SettingString(item) 169 | break 170 | case "stepper": 171 | settingItem = new SettingStepper(item).with({ min: item.min ?? 1, max: item.max ?? 12 }) 172 | break 173 | case "script": 174 | // item.script ?? item.value 兼容旧版本 175 | settingItem = new SettingScript(item).with({ script: item.script ?? item.value }) 176 | break 177 | case "tab": 178 | settingItem = new SettingTab(item).with({ items: item.items, values: item.values }) 179 | break 180 | case "menu": 181 | settingItem = new SettingMenu(item).with({ 182 | items: item.items, 183 | values: item.values, 184 | pullDown: item.pullDown ?? false 185 | }) 186 | break 187 | case "color": 188 | settingItem = new SettingColor(item) 189 | break 190 | case "date": 191 | settingItem = new SettingDate(item).with({ mode: item.mode }) 192 | break 193 | case "input": 194 | settingItem = new SettingInput(item).with({ secure: item.secure }) 195 | break 196 | case "number": 197 | settingItem = new SettingNumber(item) 198 | break 199 | case "icon": 200 | settingItem = new SettingIcon(item).with({ bgcolor: item.bgcolor }) 201 | break 202 | case "push": 203 | settingItem = new SettingPush(item).with({ view: item.view, navButtons: item.navButtons }) 204 | break 205 | case "child": 206 | settingItem = new SettingChild(item).with({ children: item.children }) 207 | break 208 | case "image": 209 | settingItem = new SettingImage(item) 210 | break 211 | default: 212 | settingItem = item 213 | settingItem.default = item.value 214 | settingItem.get = (...args) => this.get(...args) 215 | settingItem.set = (...args) => this.set(...args) 216 | } 217 | return settingItem 218 | } 219 | 220 | /** 221 | * 从 this.structure 加载数据 222 | * @returns {this} 223 | */ 224 | loadConfig() { 225 | this.#loadConfigStatus = false 226 | // 永远使用 setting 结构文件内的值 227 | const userData = this.userData ?? this.fileStorage.readAsJSON(this.dataFile, {}) 228 | 229 | const setValue = structure => { 230 | for (let i in structure) { 231 | for (let j in structure[i].items) { 232 | // item 指向 structure[i].items[j] 所指的对象 233 | let item = structure[i].items[j] 234 | if (!(item instanceof SettingItem)) { 235 | // 修改 items[j] 的指向以修改原始 this.structure 236 | // 此时 item 仍然指向原对象 237 | structure[i].items[j] = this.loader(item) 238 | // 修改 item 指向 239 | item = structure[i].items[j] 240 | } 241 | if (!item.setting) item.setting = this 242 | 243 | // 部分类型可通过此属性快速查找 tapped 244 | this.settingItems[item.key] = item 245 | 246 | if (item instanceof SettingChild) { 247 | setValue(item.options.children) 248 | } else if (!(item instanceof SettingScript || item instanceof SettingInfo)) { 249 | if (item.key in userData) { 250 | this.setting[item.key] = userData[item.key] 251 | } else { 252 | this.setting[item.key] = item.default 253 | } 254 | } 255 | } 256 | } 257 | } 258 | setValue(this.structure) 259 | this.#loadConfigStatus = true 260 | return this 261 | } 262 | 263 | hasSectionTitle(structure) { 264 | this.#checkLoadConfig() 265 | return structure[0]?.title ? true : false 266 | } 267 | 268 | setUserData(userData) { 269 | this.userData = userData 270 | return this 271 | } 272 | 273 | setStructure(structure) { 274 | this.structure = structure 275 | return this.loadConfig() 276 | } 277 | 278 | /** 279 | * 设置结构文件目录。 280 | * 若调用了 setStructure(structure) 或构造函数传递了 structure 数据,则不会加载结构文件 281 | * @param {string} structurePath 282 | * @returns {this} 283 | */ 284 | setStructurePath(structurePath) { 285 | if (!this.structure) { 286 | this.setStructure(FileStorage.readFromRootAsJSON(structurePath)) 287 | } 288 | return this 289 | } 290 | 291 | /** 292 | * 设置一个独一无二的名字,防止多个 Setting 导致 UI 冲突 293 | * @param {string} name 名字 294 | */ 295 | setName(name) { 296 | this.name = name 297 | return this 298 | } 299 | 300 | set footer(footer) { 301 | this.#footer = footer 302 | } 303 | 304 | get footer() { 305 | if (this.#footer === undefined) { 306 | let info = FileStorage.readFromRootAsJSON("config.json", {})["info"] ?? {} 307 | if (!info.version || !info.author) { 308 | try { 309 | info = __INFO__ 310 | } catch {} 311 | } 312 | this.#footer = {} 313 | if (info.version && info.author) { 314 | this.#footer = { 315 | type: "view", 316 | props: { height: 70 }, 317 | views: [ 318 | { 319 | type: "label", 320 | props: { 321 | font: $font(14), 322 | text: `${$l10n("VERSION")} ${info.version} ♥ ${info.author}`, 323 | textColor: $color({ 324 | light: "#C0C0C0", 325 | dark: "#545454" 326 | }), 327 | align: $align.center 328 | }, 329 | layout: make => { 330 | make.left.right.inset(0) 331 | make.top.inset(10) 332 | } 333 | } 334 | ] 335 | } 336 | } 337 | } 338 | return this.#footer 339 | } 340 | 341 | setFooter(footer) { 342 | this.footer = footer 343 | return this 344 | } 345 | 346 | setReadonly() { 347 | this.#readonly = true 348 | return this 349 | } 350 | 351 | set(key, value) { 352 | if (this.#readonly) { 353 | throw new SettingReadonlyError() 354 | } 355 | this.#checkLoadConfig() 356 | this.setting[key] = value 357 | this.fileStorage.write(this.dataFile, $data({ string: JSON.stringify(this.setting) })) 358 | this.callEvent("onSet", key, value) 359 | return true 360 | } 361 | 362 | getOriginal(key, _default = null) { 363 | this.#checkLoadConfig() 364 | if (Object.prototype.hasOwnProperty.call(this.setting, key)) { 365 | if (typeof this.setting[key] === "function") { 366 | return this.setting[key]() 367 | } 368 | return this.setting[key] 369 | } 370 | return _default 371 | } 372 | 373 | getItem(key) { 374 | return this.settingItems[key] 375 | } 376 | 377 | get(key, _default = null) { 378 | this.#checkLoadConfig() 379 | if (!(this.getItem(key) instanceof SettingItem)) { 380 | return this.getOriginal(key, _default) 381 | } 382 | return this.getItem(key).get(_default) 383 | } 384 | 385 | #getSections(structure) { 386 | const sections = [] 387 | for (let section in structure) { 388 | const rows = [] 389 | for (let row in structure[section].items) { 390 | let item = structure[section].items[row] 391 | // 跳过无 UI 项 392 | if (!(item instanceof SettingItem)) { 393 | continue 394 | } 395 | rows.push(item.create()) 396 | } 397 | sections.push({ 398 | title: $l10n(structure[section].title ?? ""), 399 | rows: rows 400 | }) 401 | } 402 | return sections 403 | } 404 | 405 | getListView(structure = this.structure, footer = this.footer, id = this.name) { 406 | return { 407 | type: "list", 408 | props: { 409 | id, 410 | style: 2, 411 | separatorInset: $insets( 412 | 0, 413 | SettingItem.iconSize + SettingItem.edgeOffset * 2, 414 | 0, 415 | SettingItem.edgeOffset 416 | ), // 分割线边距 417 | footer: footer, 418 | data: this.#getSections(structure) 419 | }, 420 | layout: $layout.fill, 421 | events: { 422 | rowHeight: (tableView, indexPath) => { 423 | const info = tableView.object(indexPath)?.props?.info ?? {} 424 | return info.rowHeight ?? SettingItem.rowHeight 425 | }, 426 | didSelect: async (tableView, indexPath, data) => { 427 | tableView = tableView.ocValue() 428 | 429 | const item = this.getItem(data.props.info.key) 430 | if (typeof item?.tapped === "function") { 431 | tableView.$selectRowAtIndexPath_animated_scrollPosition(indexPath.ocValue(), false, 0) 432 | try { 433 | await item.tapped() 434 | } catch (error) { 435 | this.logger.error(error) 436 | } 437 | } 438 | 439 | tableView.$deselectRowAtIndexPath_animated(indexPath, true) 440 | } 441 | } 442 | } 443 | } 444 | 445 | getNavigationView() { 446 | const navigationView = new NavigationView() 447 | navigationView.setView(this.getListView(this.structure)).navigationBarTitle($l10n("SETTING")) 448 | if (this.hasSectionTitle(this.structure)) { 449 | navigationView.navigationBar.setContentViewHeightOffset(-10) 450 | } 451 | return navigationView 452 | } 453 | 454 | getPage() { 455 | return this.getNavigationView().getPage() 456 | } 457 | } 458 | 459 | module.exports = { 460 | Setting 461 | } 462 | -------------------------------------------------------------------------------- /src/navigation-view/navigation-bar.js: -------------------------------------------------------------------------------- 1 | const { View } = require("../view") 2 | const { Controller } = require("../controller") 3 | const { UIKit } = require("../ui-kit") 4 | 5 | /** 6 | * @typedef {import("./navigation-bar-items").NavigationBarItems} NavigationBarItems 7 | */ 8 | 9 | class NavigationBar extends View { 10 | static largeTitleDisplayModeAutomatic = 0 11 | static largeTitleDisplayModeAlways = 1 12 | static largeTitleDisplayModeNever = 2 13 | 14 | /** 15 | * @type {NavigationBarItems} 16 | */ 17 | navigationBarItems 18 | 19 | title = "" 20 | 21 | prefersLargeTitles = true 22 | largeTitleDisplayMode = NavigationBar.largeTitleDisplayModeAutomatic 23 | 24 | fontFamily = "bold" 25 | largeTitleFontSize = 34 26 | largeTitleFontHeight = $text.sizeThatFits({ 27 | text: "A", 28 | width: 100, 29 | font: $font(this.fontFamily, this.largeTitleFontSize) 30 | }).height 31 | navigationBarTitleFontSize = 17 32 | topSafeArea = true 33 | contentViewHeightOffset = 0 34 | navigationBarNormalHeight = UIKit.NavigationBarNormalHeight 35 | navigationBarLargeTitleHeight = UIKit.NavigationBarLargeTitleHeight 36 | 37 | setTopSafeArea() { 38 | this.topSafeArea = true 39 | return this 40 | } 41 | 42 | removeTopSafeArea() { 43 | this.topSafeArea = false 44 | return this 45 | } 46 | 47 | setLargeTitleDisplayMode(mode) { 48 | this.largeTitleDisplayMode = mode 49 | return this 50 | } 51 | 52 | setBackgroundColor(backgroundColor) { 53 | this.backgroundColor = backgroundColor 54 | return this 55 | } 56 | 57 | setTitle(title) { 58 | this.title = title 59 | return this 60 | } 61 | 62 | setPrefersLargeTitles(bool) { 63 | this.prefersLargeTitles = bool 64 | return this 65 | } 66 | 67 | setContentViewHeightOffset(offset) { 68 | this.contentViewHeightOffset = offset 69 | return this 70 | } 71 | 72 | /** 73 | * 页面大标题 74 | */ 75 | getLargeTitleView() { 76 | return this.prefersLargeTitles && this.largeTitleDisplayMode !== NavigationBar.largeTitleDisplayModeNever 77 | ? { 78 | type: "label", 79 | props: { 80 | id: this.id + "-large-title", 81 | text: this.title, 82 | textColor: UIKit.textColor, 83 | align: $align.left, 84 | font: $font(this.fontFamily, this.largeTitleFontSize), 85 | line: 1 86 | }, 87 | layout: (make, view) => { 88 | make.left.equalTo(view.super.safeArea).offset(15) 89 | make.height.equalTo(this.largeTitleFontHeight) 90 | make.top.equalTo(view.super.safeAreaTop).offset(this.navigationBarNormalHeight) 91 | } 92 | } 93 | : { 94 | props: { id: this.id + "-large-title" }, 95 | layout: (make, view) => { 96 | make.left.top.right.inset(0) 97 | make.bottom.equalTo(view.super.safeAreaTop).offset(this.navigationBarNormalHeight) 98 | } 99 | } 100 | } 101 | 102 | getNavigationBarView() { 103 | const getButtonView = (buttons, align) => { 104 | const layout = (make, view) => { 105 | make.top.equalTo(view.super.safeAreaTop) 106 | make.bottom.equalTo(view.super.safeAreaTop).offset(this.navigationBarNormalHeight) 107 | if (align === UIKit.align.left) make.left.equalTo(view.super.safeArea) 108 | else make.right.equalTo(view.super.safeArea) 109 | } 110 | if (buttons && !Array.isArray(buttons)) { 111 | return { 112 | type: "view", 113 | views: [buttons], 114 | layout: layout 115 | } 116 | } 117 | let width = 0 118 | const buttonViews = [] 119 | buttons.forEach(button => { 120 | width += button.width 121 | buttonViews.push(button.definition) 122 | }) 123 | const edges = buttons[0]?.edges ?? 0 124 | width += buttons.length >= 2 ? edges * 2 : edges 125 | return buttons.length > 0 126 | ? { 127 | type: "view", 128 | views: buttonViews, 129 | info: { width }, 130 | layout: (make, view) => { 131 | layout(make, view) 132 | make.width.equalTo(width) 133 | } 134 | } 135 | : { type: "view", layout: make => make.size.equalTo(0) } 136 | } 137 | const leftButtonView = getButtonView( 138 | this.navigationBarItems.popButtonView ?? this.navigationBarItems.leftButtons, 139 | UIKit.align.left 140 | ) 141 | const rightButtonView = getButtonView(this.navigationBarItems.rightButtons, UIKit.align.right) 142 | const isHideBackground = this.prefersLargeTitles 143 | const isHideTitle = 144 | !this.prefersLargeTitles || this.largeTitleDisplayMode === NavigationBar.largeTitleDisplayModeNever 145 | return { 146 | // 顶部 bar 147 | type: "view", 148 | props: { 149 | id: this.id + "-navigation", 150 | bgcolor: $color("clear") 151 | }, 152 | layout: (make, view) => { 153 | make.left.top.right.inset(0) 154 | make.bottom.equalTo(view.super.safeAreaTop).offset(this.navigationBarNormalHeight) 155 | }, 156 | views: [ 157 | this.backgroundColor 158 | ? { 159 | type: "view", 160 | props: { 161 | hidden: isHideBackground, 162 | bgcolor: this.backgroundColor, 163 | id: this.id + "-background" 164 | }, 165 | layout: $layout.fill 166 | } 167 | : UIKit.blurBox({ 168 | hidden: isHideBackground, 169 | id: this.id + "-background" 170 | }), 171 | UIKit.separatorLine({ 172 | id: this.id + "-underline", 173 | alpha: isHideBackground ? 0 : 1 174 | }), 175 | { 176 | type: "view", 177 | props: { 178 | alpha: 0, 179 | bgcolor: $color("clear"), 180 | id: this.id + "-large-title-mask" 181 | }, 182 | events: { 183 | ready: sender => { 184 | sender.bgcolor = $(this.id + "-large-title")?.prev.bgcolor 185 | } 186 | }, 187 | layout: $layout.fill 188 | }, 189 | leftButtonView, 190 | rightButtonView, 191 | { 192 | type: "view", 193 | views: [ 194 | { 195 | // 标题 196 | type: "label", 197 | props: { 198 | id: this.id + "-small-title", 199 | alpha: isHideTitle ? 1 : 0, // 不显示大标题则显示小标题 200 | text: this.title, 201 | font: $font(this.fontFamily, this.navigationBarTitleFontSize), 202 | align: $align.center, 203 | bgcolor: $color("clear"), 204 | textColor: UIKit.textColor 205 | }, 206 | layout: (make, view) => { 207 | make.edges.equalTo(view.super.safeArea) 208 | const fontWidth = UIKit.getContentSize( 209 | $font(this.fontFamily, this.navigationBarTitleFontSize), 210 | view.text 211 | ).width 212 | const btnW = Math.max(leftButtonView.info?.width ?? 0, rightButtonView.info?.width ?? 0) 213 | if (UIKit.windowSize.width - btnW * 2 > fontWidth) { 214 | make.centerX.equalTo(view.super.super) 215 | } 216 | } 217 | } 218 | ], 219 | layout: (make, view) => { 220 | make.top.bottom.equalTo(view.super.safeArea) 221 | make.left.equalTo(view.prev.prev.right) 222 | make.right.equalTo(view.prev.left) 223 | } 224 | } 225 | ] 226 | } 227 | } 228 | } 229 | 230 | class NavigationBarController extends Controller { 231 | static largeTitleViewSmallMode = 0 232 | static largeTitleViewLargeMode = 1 233 | 234 | /** 235 | * @type {NavigationBar} 236 | */ 237 | navigationBar 238 | 239 | updateSelector() { 240 | this.selector = { 241 | navigation: $(this.navigationBar.id + "-navigation"), 242 | largeTitleView: $(this.navigationBar.id + "-large-title"), 243 | smallTitleView: $(this.navigationBar.id + "-small-title"), 244 | underlineView: this.navigationBar.navigationBarItems.isPinTitleView 245 | ? $(this.navigationBar.id + "-title-view-underline") 246 | : $(this.navigationBar.id + "-underline"), 247 | largeTitleMaskView: $(this.navigationBar.id + "-large-title-mask"), 248 | backgroundView: $(this.navigationBar.id + "-background"), 249 | titleViewBackgroundView: $(this.navigationBar.id + "-title-view-background") 250 | } 251 | } 252 | 253 | toNormal(permanent = true) { 254 | this.updateSelector() 255 | this.selector.backgroundView.hidden = false 256 | $ui.animate({ 257 | duration: 0.2, 258 | animation: () => { 259 | this.selector.underlineView.alpha = 1 260 | // 隐藏大标题,显示小标题 261 | this.selector.smallTitleView.alpha = 1 262 | this.selector.largeTitleView.alpha = 0 263 | } 264 | }) 265 | if (permanent && this.navigationBar.navigationBarItems) { 266 | this.navigationBar.largeTitleDisplayMode = NavigationBar.largeTitleDisplayModeNever 267 | } 268 | } 269 | 270 | toLargeTitle(permanent = true) { 271 | this.updateSelector() 272 | this.selector.backgroundView.hidden = true 273 | $ui.animate({ 274 | duration: 0.2, 275 | animation: () => { 276 | this.selector.underlineView.alpha = 0 277 | this.selector.smallTitleView.alpha = 0 278 | this.selector.largeTitleView.alpha = 1 279 | } 280 | }) 281 | if (permanent && this.navigationBar.navigationBarItems) { 282 | this.navigationBar.largeTitleDisplayMode = NavigationBar.largeTitleDisplayModeAlways 283 | } 284 | } 285 | 286 | #changeLargeTitleView(largeTitleViewMode) { 287 | const isSmallMode = largeTitleViewMode === NavigationBarController.largeTitleViewSmallMode 288 | this.selector.largeTitleView.alpha = isSmallMode ? 0 : 1 289 | $ui.animate({ 290 | duration: 0.2, 291 | animation: () => { 292 | this.selector.smallTitleView.alpha = isSmallMode ? 1 : 0 293 | } 294 | }) 295 | } 296 | 297 | #largeTitleScrollAction(contentOffset) { 298 | const titleSizeMax = 40 // 下拉放大字体最大值 299 | 300 | // 标题跟随 301 | this.selector.largeTitleView.updateLayout((make, view) => { 302 | if (this.navigationBar.navigationBarNormalHeight - contentOffset > 0) { 303 | // 标题上移致隐藏后停止移动 304 | make.top 305 | .equalTo(view.super.safeAreaTop) 306 | .offset(this.navigationBar.navigationBarNormalHeight - contentOffset) 307 | } else { 308 | make.top.equalTo(view.super.safeAreaTop).offset(0) 309 | } 310 | }) 311 | 312 | if (contentOffset > 0) { 313 | if (contentOffset >= this.navigationBar.navigationBarNormalHeight) { 314 | this.#changeLargeTitleView(NavigationBarController.largeTitleViewSmallMode) 315 | } else { 316 | this.#changeLargeTitleView(NavigationBarController.largeTitleViewLargeMode) 317 | } 318 | } else { 319 | // 切换模式 320 | this.#changeLargeTitleView(NavigationBarController.largeTitleViewLargeMode) 321 | // 下拉放大字体 322 | let size = this.navigationBar.largeTitleFontSize - contentOffset * 0.04 323 | if (size > titleSizeMax) size = titleSizeMax 324 | this.selector.largeTitleView.font = $font(this.navigationBar.fontFamily, size) 325 | } 326 | } 327 | 328 | #navigationBarScrollAction(contentOffset) { 329 | const trigger = 330 | this.navigationBar.largeTitleDisplayMode === NavigationBar.largeTitleDisplayModeNever 331 | ? 5 332 | : this.navigationBar.navigationBarNormalHeight 333 | const hasTitleView = this.selector.titleViewBackgroundView !== undefined 334 | 335 | if (contentOffset > trigger) { 336 | this.selector.backgroundView.hidden = false 337 | const uiAction = () => { 338 | if (hasTitleView && this.navigationBar.navigationBarItems.isPinTitleView) { 339 | this.selector.titleViewBackgroundView.alpha = 1 340 | } 341 | this.selector.largeTitleMaskView.alpha = 0 342 | this.selector.underlineView.alpha = 1 343 | } 344 | if ((contentOffset - trigger) / 3 >= 1) { 345 | uiAction() 346 | } else { 347 | $ui.animate({ 348 | duration: 0.2, 349 | animation: () => { 350 | uiAction() 351 | } 352 | }) 353 | } 354 | } else { 355 | this.selector.largeTitleMaskView.alpha = contentOffset > 0 ? 1 : 0 356 | this.selector.underlineView.alpha = 0 357 | if (hasTitleView) { 358 | this.selector.titleViewBackgroundView.alpha = 0 359 | } 360 | this.selector.backgroundView.hidden = true 361 | } 362 | } 363 | 364 | didScroll(contentOffset) { 365 | if (!this.navigationBar.prefersLargeTitles) return 366 | 367 | const largeTitleDisplayMode = this.navigationBar.largeTitleDisplayMode 368 | if (largeTitleDisplayMode === NavigationBar.largeTitleDisplayModeAlways) return 369 | 370 | this.updateSelector() 371 | 372 | if (largeTitleDisplayMode === NavigationBar.largeTitleDisplayModeAutomatic) { 373 | if (!this.navigationBar.navigationBarItems?.isPinTitleView) { 374 | // titleView didScroll 375 | this.navigationBar.navigationBarItems?.titleView?.controller.didScroll(contentOffset) 376 | // 在 titleView 折叠前锁住主要视图 377 | if (contentOffset > 0) { 378 | const height = this.navigationBar.navigationBarItems?.titleView?.height ?? 0 379 | contentOffset -= height 380 | if (contentOffset < 0) contentOffset = 0 381 | } 382 | } 383 | 384 | this.#largeTitleScrollAction(contentOffset) 385 | this.#navigationBarScrollAction(contentOffset) 386 | } else if (largeTitleDisplayMode === NavigationBar.largeTitleDisplayModeNever) { 387 | this.#navigationBarScrollAction(contentOffset) 388 | } 389 | } 390 | 391 | didEndDragging(contentOffset, decelerate, scrollToOffset, zeroOffset) { 392 | if (!this.navigationBar.prefersLargeTitles) return 393 | 394 | const largeTitleDisplayMode = this.navigationBar.largeTitleDisplayMode 395 | if (largeTitleDisplayMode === NavigationBar.largeTitleDisplayModeAlways) return 396 | 397 | this.updateSelector() 398 | 399 | if (largeTitleDisplayMode === NavigationBar.largeTitleDisplayModeAutomatic) { 400 | let titleViewHeight = 0 401 | if (!this.navigationBar.navigationBarItems?.isPinTitleView) { 402 | // titleView didEndDragging 403 | this.navigationBar.navigationBarItems?.titleView?.controller.didEndDragging( 404 | contentOffset, 405 | decelerate, 406 | scrollToOffset, 407 | zeroOffset 408 | ) 409 | titleViewHeight = this.navigationBar.navigationBarItems?.titleView?.height ?? 0 410 | contentOffset -= titleViewHeight 411 | } 412 | if (contentOffset >= 0 && contentOffset <= this.navigationBar.largeTitleFontHeight) { 413 | scrollToOffset( 414 | $point( 415 | 0, 416 | contentOffset >= this.navigationBar.largeTitleFontHeight / 2 417 | ? this.navigationBar.navigationBarNormalHeight + titleViewHeight - zeroOffset 418 | : titleViewHeight - zeroOffset 419 | ) 420 | ) 421 | } 422 | } 423 | } 424 | } 425 | 426 | module.exports = { 427 | NavigationBar, 428 | NavigationBarController 429 | } 430 | --------------------------------------------------------------------------------