HT UI 定义类手册

索引


定义类

ECMAScript 6 之前的语法结构没有提供定义类的方式,为了实现面向对象编程,HT 核心包提供 def 函数用于定义类, 类基础这个文档里介绍了如何定义简单类、定义包,我们就不再赘述,本文着重介绍如何定义 UI 组件和容器

HT 提供了很多 Mixin 简化类定义,常用的 Mixin 列举如下:

上面提到 ui_acms_ac 都依赖 ms_fire,从 ht.ui.border.Borderht.ui.drawable.Drawableht.ui.View 继承过程中,因为父类已经添加了 ms_fire 这个 Mixin,所以我们的子类直接使用 ms_acui_ac 即可

下面我们使用 Mixin 简化类定义:

// 定义包
com = {};
com.hightopo = {};

// define Person class constructor
com.hightopo.Person = function(firstName, lastName) {
    // 调用父类构造函数
    com.hightopo.Person.superClass.constructor.call(this);

    this.setFirstName(firstName);
    this.setLastName(lastName);
};

ht.Default.def('com.hightopo.Person', Object, {
    ms_fire: true,
    ms_ac: ['firstName', 'lastName']
});

可以看到,使用 ms_firems_ac 以后整个类的代码简化了很多;如果希望自己实现 getter/setter 函数而不是使用 ms_ac 自动生成,可以用下面的方式:

getFirstName: function() {
    return this._firstName;
},
setFirstName: function(firstName) {
    var oldValue = this._oldValue;
    this._firstName = firstName;
    // 派发属性变化事件
    this.firePropertyChange('firstName', oldValue, firstName);
}

如果希望用自己的 getter/setter 代替 ui_ac,请使用下面的方式:

getBackground: function() {
    return this.getPropertyValue('background');
},
setBackground: function(background) {
    this.setPropertyValue('background', background);
    // 其它逻辑代码
}

注意代码中我们调用父类构造的方式:

// 调用父类构造函数
com.hightopo.Person.superClass.constructor.call(this);

如果我们的子类重写了父类函数,也要通过这种方式调用父类函数:

com.hightopo.Student = function(firstName, lastName) {
    // 调用父类构造函数并传递参数
    com.hightopo.Student.superClass.constructor.call(this, firstName, lastName);
};

ht.Default.def('com.hightopo.Student', com.hightopo.Person, {
    /**
     * 重写父类函数,先通过调用父类函数获取 firstName,再拼接字符串返回
     **/
    getFirstName: function() {
        var firstName = com.hightopo.Student.superClass.getFirstName.call(this);
        return 'student:' + firstName;
    }
});

属性默认值和优先级

使用 ms_acui_ac 定义类属性以后,经常需要设置属性默认值,对于 ms_ac 声明的变量,使用 _ 加变量名就可以设置默认值,例如:

ht.Default.def('com.hightopo.Person', Object, {
    ms_fire: true,
    ms_ac: ['firstName', 'lastName'],
    _firstName: 'Li' // 此类所有的对象的 firstName 属性默认值为 `Li`
});

ui_ac 用于生成样式属性,样式属性有多种设置方式,下面是不同设置方式的优先级:

定义组件

我们已经知道了如何定义类,接下来学习如何自定义组件;下面的例子中,我们自定义一个组件,并绘制图形:

// 构造函数
ht.ui.CustomView = function () {
    ht.ui.CustomView.superClass.constructor.call(this);
};

ht.Default.def(ht.ui.CustomView, ht.ui.View, {
    // 自动生成 getShape 和 setShape
    ui_ac: ['shape', 'shapeWidth', 'shapeHeight'],
    // shape 属性默认值,注意样式属性的默认值使用双下划线开头
    __shape: 'circle',
    __shapeWidth: 100,
    __shapeHeight: 100,

    /**
     * 在入门手册中我们介绍过,组件都有首选尺寸,一般与内容有关
     * 这里重写父类函数,根据 shapeWidth、shapeHeight 再加上 padding 和 border 作为组件的首选大小
     * */
    figurePreferredSize: function () {
        var self = this,
            shapeWidth = self.getShapeWidth(),
            shapeHeight = self.getShapeHeight(),
            size = {
                width: self.getPaddingLeft() + self.getPaddingRight() +
                        self.getBorderLeft() + self.getBorderRight(),
                height: self.getPaddingTop() + self.getPaddingBottom() +
                        self.getBorderTop() + self.getBorderBottom()
            };
        size.width += shapeWidth;
        size.height += shapeHeight;

        return size;
    },

    /**
     * 此组件的首选大小与 shapeWidth、shapeHeight 有关,如果这两个属性如果发生变化,就需要重新计算首选大小
     * 所以需要重写父类 getPreferredSizeProperties 函数,将 shapeWidth 和 shapeHeight 放到影响首选大小的属性 map 中并返回
     * */
    getPreferredSizeProperties: function() {
        // 获取父类中会影响 preferredSize 的属性 map
        var preferredSizeProperties = ht.ui.CustomView.superClass.getPreferredSizeProperties.call(this);
        // 因为 map 是引用类型,为了避免影响原始 map,我们需要将其克隆
        preferredSizeProperties = ht.Default.clone(preferredSizeProperties);

        // 将 shapeWidth 和 shapeHeight 放到 map 中
        preferredSizeProperties.shapeWidth = true;
        preferredSizeProperties.shapeHeight = true;

        // 返回 map
        return preferredSizeProperties;
    },

    // 重写父类 validateImpl 函数,绘制形状
    validateImpl: function (x, y, width, height) {
        var self = this;
        ht.ui.CustomView.superClass.validateImpl.call(self, x, y, width, height);

        var g = self.getRootContext(),
            shape = self.getShape();

        // 正如入门手册中介绍的,所有绘制的开头都要加上 g.beginPath(),防止内部路径被重新绘制
        g.beginPath();
        g.fillStyle = 'yellow';
        if (shape === 'rect') {
            g.rect(x, y, width, height);
        }
        else {
            g.arc(x + width / 2, y + height / 2, Math.min(width, height) / 2, 0, Math.PI * 2);
        }
        g.fill();
    }
});

在这里例子里,我们重写了 figurePreferredSize 来计算首选大小,创建这个组件的时候,就可以根据它的首选大小放到页面中:

customView = new ht.ui.CustomView();
customView.setBorder(new ht.ui.border.LineBorder(1, '#0092d4'));
customView.setPadding(20);
customView.setShape('rect');
// 获取首选大小
var preferredSize = customView.getPreferredSize();
// 将首选大小中的宽度和高度作为组件最终显示的宽度和高度
customView.addToDOM(window, { x: 10, y: 10, width: preferredSize.width, height: preferredSize.height });

组件交互

绝大多数组件需要与用户做互动,比如按钮组件,鼠标滑过时改变背景色,我们继续改造上面的例子,增加交互器:

// 定义交互器
ht.ui.CustomViewInteractor = function (comp) {
    ht.ui.CustomViewInteractor.superClass.constructor.call(this, comp);
}
ht.Default.def(ht.ui.CustomViewInteractor, ht.ui.Interactor, {
    // 处理鼠标按下事件
    handle_mousedown: function (e) {
        this.handle_touchstart(e);
    },
    // 处理 touch 事件
    handle_touchstart: function (e) {
        var self = this;
        if (ht.Default.isLeftButton(e)) {
            // 获取组件对象
            var customView = self.getComponent();
            // 改组件的 shape 属性
            if (customView.getShape() === 'circle') {
                customView.setShape('rect');
            }
            else {
                customView.setShape('circle');
            }
        }
    }
});

我们的交互器从 ht.ui.Interactor 继承,并且实现了 handle_mousedownhandle_touchstart 两个事件回调函数处理鼠标和 touch 事件, 在事件处理中更改组件的 shape 属性。

重写 setUpInteractors 的方式仍然可用,但是我们更推荐下面的 getInteractorClasses

为了将交互器关联到组件上,需要重写组件的 getInteractorClasses 函数:

getInteractorClasses: function () {
    return [ht.ui.CustomViewInteractor];
}

UI 库会遍历这个函数返回的数组,依次创建相应的交互器实例,交互器会遍历自身的 handle_xxx 函数,如果发现 xxx 匹配某种事件类型,就会在组件的 InteractionDiv 上增加相应的事件监听;交互器支持的事件回调函数如下:

最后运行效果如下(点击切换绘制的图形):

有些情况下,比如拖拽,即使鼠标移出了组件范围,我们还是希望能够继续处理事件,所以交互器提供了下面的回调函数:

这四种事件回调函数与上面提到的 handle_xxx 事件处理函数不同,它们并不会一开始就在 window 上加监听,而是需要开发者手动加监听,参考下面的例子(拖拽移动矩形):

ht.ui.CustomViewInteractor = function (comp) {
    ht.ui.CustomViewInteractor.superClass.constructor.call(this, comp);
}
ht.Default.def(ht.ui.CustomViewInteractor, ht.ui.Interactor, {
    handle_mousedown: function (e) {
        this.handle_touchstart(e);
    },
    handle_touchstart: function (e) {
        var self = this;
        if (ht.Default.isLeftButton(e)) {
            ht.Default.preventDefault(e);
            var customView = self.getComponent(),
                point = customView.getViewPoint(e);

            customView.setCenter(point);
            // 在 window 上注册监听函数
            ht.Default.startDragging(self);
        }
    },
    handleWindowMouseMove: function (e) {
        this.handleWindowTouchMove(e);
    },
    handleWindowTouchMove: function (e) {
        ht.Default.preventDefault(e);
        var self = this,
            customView = self.getComponent(),
            point = customView.getViewPoint(e);
        customView.setCenter(point);
    },
    handleWindowMouseUp: function (e) {
    },
    handleWindowTouchEnd: function (e) {
    }
});

在这个交互器的 handle_touchstart 中调用了 ht.Default.startDragging(self),这个函数内部会遍历交互器的 handleWindowXXX 函数,如果发现 XXX 匹配某种事件类型,就会在 window(浏览器窗口)上增加相应的事件监听;鼠标松开后 window 上的监听器会自动被移除

另外一个细节是 point = customView.getViewPoint(e)point 格式为 { x: x, y: y },表示鼠标相对组件左上角的坐标;我们把这个坐标设置到组件的 center 属性上,让组件内的矩形以此为中心绘制

浏览器的拖拽操作会有一些默认行为,比如选中文本,改变鼠标样式等给用户造成困惑,所以我们调用了 ht.Default.preventDefault(e) 阻止这些浏览器默认行为

定义布局器

ht.ui.ViewGroupUI 库中所有布局器的基类,开发者要自定义布局器,也需要从此类继承;下面的例子中,我们尝试定义一个简化版的 HBoxLayout(将子组件从左侧往右排列):

// 构造函数
var SimpleHBoxLayout = function () {
    SimpleHBoxLayout.superClass.constructor.call(this);
}
ht.Default.def(SimpleHBoxLayout, ht.ui.ViewGroup, {
    // 计算布局器的首选大小,遍历所有的子组件并取宽度之和和高度的最大值作为首选尺寸
    figurePreferredSize: function () {
        var self = this,
            children = self.getVisibleChildren(),
            size = {
                width: self.getPaddingLeft() + self.getPaddingRight() +
                    self.getBorderLeft() + self.getBorderRight(),
                height: self.getPaddingTop() + self.getPaddingBottom() +
                    self.getBorderTop() + self.getBorderBottom()
            };

        for (var i = 0, size = children.size(); i < size; i++) {
            var child = children.get(i),
                childPreferredSize = child.getPreferredSize(),
                layoutParams = self.getChildLayoutParams(child) || {},
                marginLeft = layoutParams.marginLeft || 0,
                marginRight = layoutParams.marginRight || 0,
                marginTop = layoutParams.marginTop || 0,
                marginBottom = layoutParams.marginBottom || 0;

            size.width += childPreferredSize.width + marginLeft + marginRight;
            size.height = Math.max(size.height, marginTop + marginBottom + childPreferredSize.height);
        }
        return size;
    },
    // 对子组件进行布局
    validateImpl: function (x, y, width, height) {
        var self = this;
        SimpleHBoxLayout.superClass.validateImpl.call(self, x, y, width, height);

        var children = self.getVisibleChildren(),
            size = children.size(),
            layoutX = 0,
            scrollWidth = 0,
            scrollHeight = 0;
        for (var i = 0; i < size; i++) {
            var child = children.get(i),
                preferredSize = child.getPreferredSize(),
                layoutParams = self.getChildLayoutParams(child) || {},
                marginLeft = layoutParams.marginLeft || 0,
                marginRight = layoutParams.marginRight || 0,
                marginTop = layoutParams.marginTop || 0,
                marginBottom = layoutParams.marginBottom || 0;

            // 布局子组件:设置子组件的位置、尺寸并刷新子组件
            self.layoutChild(child, layoutX + marginLeft, marginTop, preferredSize.width, preferredSize.height);
            layoutX += marginLeft + preferredSize.width + marginRight;
        }
        // 调用 updateScrollBar 更新滚动条(layoutX 为滚动宽度)
        self.updateScrollBar(width, height, layoutX, scrollHeight);
    }
});

自定义组件的序列化

ht.ui.UIJSONSerializer 类用于对组件进行序列化和反序列化,如果是自定义的组件,需要实现序列化接口;上面自定义的 ht.ui.CustomView 组件增加 getSerializableProperties 函数即可支持序列化:

getSerializableProperties: function() {
    // 先获取父类可序列化属性
    var parentProperties = ht.ui.CustomView.superClass.getSerializableProperties.call(this);
    // 在父类可序列化属性的基础上,新增 shape、shapeWidth、shapeHeight 三个属性
    return ht.Default.addMethod(parentProperties, {
        shape: true,
        shapeWidth: true,
        shapeHeight: true
    });
}

对于某些复杂类型(比如日期对象)不能直接序列化和反序列化,需要重写 serializePropertydeserializeProperty

serializeProperty: function (value, name, serializer) {// 参数:属性值、属性名、当前执行序列化的 JSONUISerializer 对象
    var self = this;
    if (value instanceof Date) {
        return value.getTime();
    }
    else {
        // 其它属性继续由序列化器处理
        return serializer.serializeProperty(value, name, self);
    }
},
deserializeProperty: function (value, name, serializer) {
    var self = this;
    if (name === 'date') { // 日期属性,根据业务确定
        return new Date(value);
    }
    else {
        // 其它属性继续由序列化器处理
        return serializer.deserializeProperty(value, name, self);
    }
}

日期对象不能直接使用 JSON.stringifyJSON.parse 处理,所以我们重写了上面两个函数,转换成数字处理


欢迎交流 service@hightopo.com