├── 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 |
--------------------------------------------------------------------------------