曾经介绍过用SWT实现MSN风格的下拉框,SWT虽然没有Swing那么强大,尤其是在打造专业外观上,不支持L&F,但是通过自定义组件,同样可以达到用户要求。下面就向大家介绍本人实现的一个具备专业外观的Slider控件。
首先来参考一下组件的实际运行效果,并和SWT原生组件进行一下对比。
可以看出,经过自定义的组件在外观上要比SWT直接调用本地组件显得更加专业。当用户托拽滑动块时,还会出现一个虚拟的滑动块用来标识将要移动到的位置。演示就到此为止,下面详细介绍这个很Cool的组件是如何通过SWT实现的。
基本设计思想:与其他自定义组件一样,是通过继承org.eclipse.swt.widgets.Composite来实现,定义该类为Slider,另外滑动块(thumb)也是Composite,并放在Slider之上,当鼠标移动thumb时,调用setBounds方法定位在Slider在父组件(Slider)上的位置,从而达到拖拽thumb的目的。此外通过实现PaintListener接口进行自定义绘制,绘制的对象包括组件边框、被填充的格子、未被填充的格子、虚拟滑块。
接触过GUI编程的程序员都应该知道像Scroll、Slider、ProgressBar这样的控件都有setMaxValue、setMinValue、setValue这样的方法,除了鼠标拖拽thumb来改变当前数值外,可直接调用setValue来设置当前值。此外这些控件还有水平(Horizontal)、垂直(Vertical)两种布局,对于事件处理一般都要有一个从java.util.EventObject继承而来的事件类,还要编写事件监听器(Listener)接口,因此在开始编写Slider控件之前先定义3个类,代码都不是很长,如果你熟悉AWT、Swing的事件处理机制,相信你能轻松跳过。
public enum SliderOrientation {
HORIZONTAL, VERTICAL;
}
public class SliderEvent extends EventObject {
private int value;
public SliderEvent(Object source, int value) {
super(source);
this.value = value;
}
public int getValue() {
return value;
}
}
public interface SliderListener {
public void valueChanged(SliderEvent event);
}
接下来着重介绍Slider。首先是继承Composite并实现ControlListener、PaintListener、MouseListener,、MouseMoveListener,、MouseTrackListener,然后自动生成接口方法代码,通过Eclipse可以轻松实现,需要注意的是MouseListener,有java.awt.event.MouseListener和org.eclipse.swt.events.MouseListener两种,不要混淆,否则错误很难找到。然后是要采集一些数据信息,分别是:边框颜色、已有数据部分的填充颜色(上图中组件的绿色部分)、未达到数据部分的填充颜色(上图中组件的白色部分)、被禁用时的填充颜色、水平滑块的图标(正常、托拽中两种)、垂直滑块图标(正常、托拽中两种)、水平、垂直虚拟滑块图标。以上这些数据对应的常量声明如下:
private final Color BORDER_COLOR = new Color(Display.getCurrent(), 180, 188, 203);
private final Color FILL_COLOR = new Color(Display.getCurrent(), 147, 217, 72);
private final Color BLANK_COLOR = new Color(Display.getCurrent(), 254, 254, 254);
private final Color DISABLE_COLOR = new Color(Display.getCurrent(), 192, 192, 192);
private final Image THUMB_ICON_V = new Image(Display.getDefault(), "slider_up_v.png");
private final Image THUMB_OVER_ICON_V = new Image(Display.getDefault(), "slider_over_v.png");
private final Image THUMB_ICON_H = new Image(Display.getDefault(), "slider_up_h.png");
private final Image THUMB_OVER_ICON_H = new Image(Display.getDefault(), "slider_over_h.png");
private final Image TEMP_H = new Image(Display.getDefault(), "temp_h.png");
private final Image TEMP_V = new Image(Display.getDefault(), "temp_v.png");
除了这些常量,还应该声明默认最大值的常量,private final int DEFAULT_MAX_VALUE = 100;
接下来定义当前数值和最大值,
private int value;
private int maxValue = DEFAULT_MAX_VALUE;
并生成以上两个成员属性的get方法
然后定义滑动块和布局
private SliderOrientation orientation;
private Composite thumb;
要处理数值变化,需要实现一组监听器,添加如下代码
private List<SliderListener> listeners = new ArrayList<SliderListener>();
public void addSliderListener(SliderListener sliderListener) {
listeners.add(sliderListener);
}
public void removeSliderListener(SliderListener sliderListener) {
listeners.remove(sliderListener);
}
接下来定义2个辅助方法,实现value<->pelsLength转换。其中value是当前的数值,由具体业务来决定,下文中称其业务值。例如一个音量控制器,音量范围在0~500,那么从业务上来讲可以将数值设置在0~500之间的任何数,而pelsLength则由控件的长/高度来决定,单位是像素。但是value与pelsLength之间存在着一个比例关系式:value/maxValue=pelsLength/控件长度或高度。这样不难得出两个函数的定义。
private int valueToPels(int value) {
float widgetLength = (orientation == SliderOrientation.HORIZONTAL) ? getBounds().width
: getBounds().height;
return (int) (widgetLength * (float) value / (float) maxValue);
}
private int pelsToValue(int pels) {
float widgetLength = (orientation == SliderOrientation.HORIZONTAL) ? getBounds().width
: getBounds().height;
return (int) ((float) pels * (float) maxValue / (float) widgetLength);
}
最后定义构造器。代码如下
public Slider(Composite parent, SliderOrientation orientation) {
super(parent, SWT.FLAT);
this.orientation = orientation;
thumb = new Composite(this, SWT.FLAT);
thumb
.setBackgroundImage(orientation == SliderOrientation.VERTICAL ? THUMB_ICON_V
: THUMB_ICON_H);
addControlListener(this);
addPaintListener(this);
thumb.addMouseListener(this);
thumb.addMouseMoveListener(this);
thumb.addMouseTrackListener(this);
}
在构造器中,注入布局对象,然后在控件上创建滑动块组件thumb,并添加鼠标处理等。
到此为止,基本的成员和方法的定义完毕,下面循序渐进讨论如何实现这一Slider。
一、绘制边框
由于是绘制操作,所以一切绘制代码均在paintControl方法内实现,先将如下代码拷贝到paintControl内
int w = getBounds().width;
int h = getBounds().height;
int fillLength = valueToPels(getValue());
GC gc = e.gc;
switch (orientation) {
case HORIZONTAL:
break;
case VERTICAL:
break;
}
分析如下
首先获取控件的长度与高度,因为接下来的绘制要经常用到这两个变量。
“int fillLength = valueToPels(getValue());”这一行代码稍后作解释,然后是获得绘制上下文对象,下一步是根据布局不同采用不同的处理,除了paintControl函数,在其他很多地方都对布局进行判断,但是简单起见,只对水平布局进行介绍,垂直部分参考完整程序。
接下来的绘制操作均在case HORIZONTAL中进行,首先将颜色设置为边框的颜色
gc.setForeground(BORDER_COLOR);然后绘制一个矩形gc.drawRectangle(0, 2, w - 1, h - 5);
关于为什么要偏移2像素、长度为什么减1、高度为什么减5,请参考有关绘图的基本知识,上一篇也有简单的介绍。
现在,你就可以编写测试程序来验证结果了,看看边框是否与示例的效果一样。
二、托拽thumb的实现
桌面GUI编程领域技术深浅的度量衡通常有4项指标:皮肤(外观,swing组件体系称其L&F)、绘图、自定义组件布局(Layout)、自定义组件。而托拽是实现自定义组件和绘图不可或缺的技术,也是难点之一,因此掌握的深浅是衡量桌面编程水平的标志。
虽然作为难点,但是也有章可循,其基本实现简单到只监听鼠标事件这么简单,基本流程是:当鼠标在thumb上按下时,记住这个位置,然后按住鼠标左键托拽,最后松开鼠标计算两个位置之间的距离(位移),根据位移量移动thumb的位置并换算出等价的value增量(可能为负值)进行业务逻辑处理。下面通过代码循序渐进完成。
定义一个位置变量用来存储鼠标单击的位置,private Point controlPoint;然后实现public void mouseDown(MouseEvent e)和public void mouseUp(MouseEvent e)两个方法。
public void mouseDown(MouseEvent e) {
controlPoint = new Point(e.x, e.y);
thumb
.setBackgroundImage(orientation == SliderOrientation.VERTICAL ? THUMB_OVER_ICON_V
: THUMB_OVER_ICON_H);
}
public void mouseUp(MouseEvent e) {
try {
thumb
.setBackgroundImage(orientation == SliderOrientation.VERTICAL ? THUMB_ICON_V
: THUMB_ICON_H);
countValue(e);
} finally {
controlPoint = null;
}
}
“controlPoint = new Point(e.x, e.y);”这一行实现记住鼠标点的位置,注意,这个位置是相对滑块thumb的,因为是thumb监听的鼠标事件。接下来是设置滑动块背景,不难理解当鼠标松开时,应该将背景恢复。然后进行非常重要的换算工作,通过countValue方法实现,最后务必要把鼠标位置清空,用try-finally是有必要这样的。之所以要在方法结束的时候清空controlPoint,是因为在鼠标移动的时候需要对controlPoint进行更加复杂的计算。稍后讲解mouseMove实现的时候再作解释,接下来着重分析countValue方法。
如前所述,countValue完成计算鼠标按下、松开的位移量,换算成与业务相关的数据(value)。
private void countValue(MouseEvent e) {
switch (orientation) {
case HORIZONTAL:
int movedX = e.x - controlPoint.x;
setValue(getValue() + pelsToValue(movedX));
break;
case VERTICAL:
......
}
}
“int movedX = e.x - controlPoint.x;”实现了位移量的计算,保存到movedX,调用pelsToValue方法将movedX转换成业务值的增量,然后调用setValue重新赋值,注意pelsToValue得到的是增量,需要与原值(getValue()得到)叠加。接下来分析setValue方法。
public void setValue(int value) {
if (value < 0) {
this.value = 0;
} else if (value > getMaxValue()) {
this.value = getMaxValue();
} else {
this.value = value;
}
try {
moveThumb();
redraw();
} finally {
for (SliderListener listener : listeners) {
try {
SliderEvent event = new SliderEvent(this, getValue());
listener.valueChanged(event);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
方法开始处的一系列if语句对value进行验证后再赋值,然后是调用moveThumb实现滑块移动、调用redraw对组件重新绘制,最后是处理具体的业务,可以看出处理业务是setValue方法的关键,所以要用try-finally,谁也不敢确保moveThumb、redraw不出问题。对于实现业务是遍历监听器列表然后执行每个监听器的valueChanged方法,这种事件源-监听器模型也是Java2以后的GUI事件实现模型。
private void moveThumb() {
Image icon = thumb.getBackgroundImage();
int iconw = (icon != null) ? icon.getBounds().width : 0;
int iconh = (icon != null) ? icon.getBounds().height : 0;
switch (orientation) {
case HORIZONTAL:
int x = valueToPels(getValue()) - iconw / 2;
if (x < 0) {
x = 0;
} else if (x > getBounds().width - iconw) {
x = getBounds().width - iconw;
}
thumb.setBounds(x, 0, iconw, iconh);
break;
case VERTICAL:
...... }
}
不难理解,moveThumb的任务就是根据业务值value来将滑块移动到正确的位置。
以上代码声明滑块的位置是“x”,通过转换函数获得,但是还要减去滑块的一半,因为具体坐标应该落到滑动块的中间,仔细想想不难得出。if-else是对x进行验证,最后通过setBound来定位thumb。
对于redraw,他的作用是触发paintControl方法进行重绘,因为重绘出了边框还要根据value绘制填充格子,而value已经在redraw方法调用前被赋了值,所以这时候应该进行重绘。
现在你可以托拽thumb了,美中不足的是滑动块不能随时跟随鼠标的轨迹移动,这个稍后会实现。
三、填充格子
现在的组件外观只是绘制了边框,现在进行格子填充。在讲述边框绘制的时候提到了一行代码“int fillLength = valueToPels(getValue());”现在不难理解吧,就是将当前业务值value转换成实际的长度。在绘制边框之后,加入如下代码:
if (getEnabled()) {
gc.setBackground(FILL_COLOR);
for (int i = 2; i < w - 2; i += 4) {
if (i > fillLength) {
gc.setBackground(BLANK_COLOR);
}
gc.fillRectangle(i, 4, 3, h - 8);
}
} else {
gc.setBackground(DISABLE_COLOR);
gc.fillRectangle(1, 4, w - 1, h - 8);
}
首先判断是否是enable,然后设置填充颜色FILL_COLOR,然后在for循环中执行正方形格子的填充,递增量“i”从2开始是空开2像素间隔,同理i也不能超过w-2(对称性),i+=4是相邻两个格子左边坐标间距4像素,然后“gc.fillRectangle(i, 4, 3, h - 8);”这一行进行填充绘制正方形。留意,x坐标是i,y坐标是从4开始画的,出于对称高度也要“h-8”,长度之所以是“3”是保持相邻两个格子之间保持1像素的间隔(想想“i+=4”就不难得出答案)。此外还要对fillLength进行判断以便决定颜色采用绿色还是白色以示区分。对于绘图操作来说,千万不要埋怨考虑细节过多,事实上,GUI编程过程中“坐标系”这个概念是需要经常被考虑的,试想如果上述代码for循环中把“i+=4”,写成“i+=5”,只是一个像素之差绘制效果差之千里,如果想知道笔者是如何得到这些坐标数据的,实话说,是靠多次调试得出的结果。
现在你运行程序,应该能根据value进行格子填充了。到此为止,绝大多数的功能已经实现,但是人性化的界面设计应该在托拽时出现一个虚拟的滑动块用来标识将要移动到的位置。好,继续实现这一功能。
定义一个int变量纪录thumb的临时位置,private int tempLocation;然后在paintControl添加如下红色代码
case HORIZONTAL:
gc.setForeground(BORDER_COLOR);
gc.drawRectangle(0, 2, w - 1, h - 5);
if (getEnabled()) {
gc.setBackground(FILL_COLOR);
for (int i = 2; i < w - 2; i += 4) {
if (i > fillLength) {
gc.setBackground(BLANK_COLOR);
}
gc.fillRectangle(i, 4, 3, h - 8);
}
if (controlPoint != null) {
gc.drawImage(TEMP_H, tempLocation, 0);
}
} else {
gc.setBackground(DISABLE_COLOR);
gc.fillRectangle(1, 4, w - 1, h - 8);
}
break;
很直观,对于水平布局就是在(tempLocation,0)画出“TEMP_H”图标。外面的if很重要,还记得mouseDown方法中的语句“controlPoint = new Point(e.x, e.y);”和mouseUp方法中的语句“controlPoint = null;”吗,当鼠标按下托拽开始时,为controlPoint赋值,当鼠标完成托拽松开时,将controlPoint置null,在这个托拽过程中controlPoint一直保持非null状态,所以paintControl方法才将图标画出。如果现在就着急运行程序则会发现,鼠标托拽时候虚拟图标总停留在最左边(如果垂直布局停留在最上边,因为并没有为tempLocation赋值默认是0),不会跟随鼠标移动。如果要实现真正的托拽那么必须在鼠标移动时反复执行paintControl,下面花大量的笔墨详细地介绍mouseMove方法的实现,并简单介绍绘图操作的一些原理和流程。
开门见山,直接将mouseMove函数的全部代码列出。
public void mouseMove(MouseEvent e) {
if (controlPoint == null) {
return;
}
int maxLength;
int maxLocator;
switch (orientation) {
case HORIZONTAL:
maxLength = valueToPels(getMaxValue());
maxLocator = maxLength - TEMP_H.getBounds().width;
int movedX = e.x - controlPoint.x;
redraw(tempLocation, 0, TEMP_H.getBounds().width,
TEMP_H.getBounds().height, false);
tempLocation = valueToPels(getValue()) + movedX
- TEMP_H.getBounds().width / 2;
if (tempLocation < 0) {
tempLocation = 0;
} else if (tempLocation > maxLocator) {
tempLocation = maxLocator;
}
break;
case VERTICAL:
......
break;
}
}
最前面的if语句表明,只有鼠标按下时移动鼠标才算托拽,道理前面已经阐明了。maxLength代表最大值转换得到的像素,maxLocator是虚拟图标左端(上端)最大坐标,movedX代表托拽的位移量。最下面的if-else if目的很明了。整个mouseMove函数中
redraw(tempLocation, 0, TEMP_H.getBounds().width,
TEMP_H.getBounds().height, false);
tempLocation = valueToPels(getValue()) + movedX
- TEMP_H.getBounds().width / 2;
是整个托拽操作最难懂也是技术含量最高的两条语句。简单起见暂时用下面的语句代替
tempLocation = valueToPels(getValue()) + movedX
- TEMP_H.getBounds().width / 2;
if (tempLocation < 0) {
tempLocation = 0;
} else if (tempLocation > maxLocator) {
tempLocation = maxLocator;
}
redraw();
其中“tempLocation”的赋值语句不变,变化的是redraw函数的调用位置和参数。这样的变化使得意思就不难理解了,首先确定tempLocation的值,等号右边的计算结果也不难理解,然后调用redraw方法重画组件。如果这时候你运行程序,托拽时确实虚拟光标会跟随鼠标移动,但是也会发现组件闪烁得很厉害!具体程度取决于用户计算机的性能,关于“组件重绘时闪烁”的问题是绘图操作的一个常见问题,不仅仅是Java,任何支持绘图的计算机语言都可以暴露这样的问题,当年在大学用MFC、VB的编写过画图板的人应该熟悉这类问题。
在具体讨论之前先简单讲述鼠标监听器中的mouseMove操作
一旦为GUI组件添加鼠标移动监听器,当鼠标光标在组件上移动时便调用监听器接口的mouseMove(MouseEvent e)方法,mouseMove调用的频率与鼠标移动的快慢有关,移动越快mouseMove被调用的次数就越少,反之就越多。假设鼠标从组件上的A点移动到B点,如果鼠标移动得足够快,那么就可以理解为从A直接到B而不经过中间的任何一个点,那么mouseMove函数仅仅调用一次。反之鼠标慢慢从A移动到B,那么中间可能会经过C、D、E、F......一系列的点,而mouseMove也会被执行多次。总之当鼠标在组件上移动时,mouseMove会频繁被调用,之所以闪烁问题出在redraw方法,如果redraw方法不加任何参数那么将对组件全部重绘,对于鼠标托拽这种操作鼠标每移动一次就要对组件全部重绘,性能的代价可想而知,不闪才怪呢。解决的办法就是只重绘变化的部分。
如何做到这一点,要先了解必要的绘图机制,在SWT中(Swing绘图原理与其类似)redraw其实会包含2个含义,擦除、绘制,所以得名于redraw,意思就是重绘。首先是根据传入redraw方法的参数确认需要擦除的范围,如果没有则擦除全部,然后底层会创建一个绘制请求交给操作系统去处理,但是这个请求不会在redraw方法调用完毕后立即被处理,即重绘操作不会立即执行,它是被送进底层的事件队列中。处理时的绘制操作是由paintControl完成,所以redraw的调用会导致paintControl的执行。
再将那两行代码列出来。
redraw(tempLocation, 0, TEMP_H.getBounds().width,
TEMP_H.getBounds().height, false);
tempLocation = valueToPels(getValue()) + movedX
- TEMP_H.getBounds().width / 2;
如果光标从A移到B处,redraw方法所做的事情是通知重绘光标在A点时虚拟图标范围内的图像,然后立即创建一个绘制请求送到系统事件队列,然后更新tempLocation的值。现在再将绘制的那部分代码列出
if (controlPoint != null) {
gc.drawImage(TEMP_H, tempLocation, 0);
}
将这两部分代码列出来做个比较,还有非常重要的一点需要指出,托拽时虚拟光标能呈连续性移动,这一功能之所以能得以实现非常重要的一点是:从底层请求送入事件队列到请求被执行存在时间差,利用这个时间差(时间非常短)可以执行一些“更新操作”,比如上面的更新tempLocation。简单的理解是redraw方法调用会以异步方式执行擦除、绘制。当执行绘制操作“gc.drawImage(TEMP_H, tempLocation, 0);”时,tempLocation已经是更新后的值了,而redraw表明擦除旧区域的图像,因为当paintControl执行时这部分图像区域根据计算结果已经不再是虚拟滑块了。下面做一个试验。
添加下面红色代码
redraw(tempLocation, 0, TEMP_H.getBounds().width,
TEMP_H.getBounds().height, false);
try {
Thread.sleep(1000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
tempLocation = valueToPels(getValue()) + movedX
- TEMP_H.getBounds().width / 2;
这样在绘制执行时,tempLocation还没有得到更新,效果运行下知晓。
现在,你可以运行完整的程序了,看一下托拽时的效果。
完整的程序这里下载