九、实现你自己的 EPG

到目前为止,我们已经构建了非常基本的例子来展示安卓为我们提供的实现和绘制自定义视图的一些功能和方法。在本章中,我们将看到一个更复杂的自定义视图示例。我们将建立一个电子编程指南 ( EPG )。

EPG 是一个相当复杂的组件,如果做错了,会影响用户体验。例如,如果它表现不好,使用起来会感觉迟钝和乏味。

我们将使用前几章已经介绍过的一些东西。总的来说,这可能有点过分,但我们将一步一步地构建它,更详细地说,我们将涵盖:

  • 如何构建基本的 EPG 定制视图
  • 如何添加基本动画和交互
  • 如何允许缩放
  • 使其可配置

建设 EPG

如果我们想让我们的 EPG 有用,它应该同时显示几个频道,以及当前和未来的电视节目。此外,最好能清楚地看到当前正在播放的内容,并清楚地指示其他电视节目的开始和结束时间。

在这个特定的组件中,我们将选择一种覆盖这些点的渲染方法。你可以用它作为一个例子,但是还有很多其他的方法来呈现同样的信息。此外,它不会连接到提供 EPG 数据的后端服务。所有 EPG 的数据都将被模拟,但它可以很容易地连接到任何服务,尽管可能需要做一些改变。

EPG 基础和动画设置

我们将从创建一个类扩展视图开始。在它的onDraw()方法上我们将绘制以下部分:

  • 视图背景
  • 拥有所有频道和电视节目的 EPG 机构
  • 提示时间的顶部时间条
  • 指示当前时间的垂直线

如果我们激活一些变量,我们还需要触发一个重绘循环。

那么,让我们从onDraw()方法的这个实现开始,让我们一个接一个地进行:

@Override 
protected void onDraw(Canvas canvas) { 
   animateLogic(); 

   long currentTime = System.currentTimeMillis(); 

   drawBackground(canvas); 
   drawEPGBody(canvas, currentTime, frScrollY); 
   drawTimeBar(canvas, currentTime); 
   drawCurrentTime(canvas, currentTime); 

   if (missingAnimations()) invalidate(); 
} 

最容易实现的方法是drawBackground():

private static final int BACKGROUND_COLOR = 0xFF333333; 
private void drawBackground(Canvas canvas) { 
    canvas.drawARGB(BACKGROUND_COLOR >> 24,  
            (BACKGROUND_COLOR >> 16) & 0xff, 
            (BACKGROUND_COLOR >> 8) & 0xff,  
            BACKGROUND_COLOR & 0xff); 
} 

在这种情况下,我们已经定义了一个背景颜色为0xFF333333,这是某种深灰色,我们只是用drawARGB()调用填充整个屏幕,掩蔽和移动颜色分量。

现在,让我们采用drawTimeBar()方法:

private void drawTimeBar(Canvas canvas, long currentTime) { 
    calendar.setTimeInMillis(initialTimeValue - 120 * 60 * 1000); 
    calendar.set(Calendar.MINUTE, 0); 
    calendar.set(Calendar.SECOND, 0); 
    calendar.set(Calendar.MILLISECOND, 0); 

    long time = calendar.getTimeInMillis(); 
    float x = getTimeHorizontalPosition(time) - frScrollX + getWidth()
             / 4.f; 

    while (x < getWidth()) { 
        if (x > 0) { 
            canvas.drawLine(x, 0, x, timebarHeight, paintTimeBar); 
        } 

        if (x + timeBarTextBoundaries.width() > 0) { 
            SimpleDateFormat dateFormatter = 
                    new SimpleDateFormat("HH:mm", Locale.US); 

            String date = dateFormatter.format(new Date(time)); 
            canvas.drawText(date, 
                    x + programMargin, 
                    (timebarHeight - timeBarTextBoundaries.height()) /
                    2.f + timeBarTextBoundaries.height(),paintTimeBar); 
        } 

        time += 30 * 60 * 1000; 
        x = getTimeHorizontalPosition(time) - frScrollX + getWidth() /
            4.f; 
    } 

    canvas.drawLine(0, 
            timebarHeight, 
            getWidth(), 
            timebarHeight, 
            paintTimeBar); 
} 

让我们解释一下这个方法的作用:

  1. 首先,我们得到了开始绘制时间标记的初始时间:
calendar.setTimeInMillis(initialTimeValue - 120 * 60 * 1000); 
calendar.set(Calendar.MINUTE, 0); 
calendar.set(Calendar.SECOND, 0); 
calendar.set(Calendar.MILLISECOND, 0); 

long time = calendar.getTimeInMillis();  

我们在我们的类构造函数中把initialTimeValue定义为到当前时间的半小时。我们还删除了分钟、秒钟和毫秒,因为我们想要指示精确的小时和每小时过去的精确半小时,例如:9.00、9.30、10.00、10.30,等等。

然后,我们创建了一个助手方法,根据将在代码的许多其他地方使用的时间戳来获取屏幕位置:

private float getTimeHorizontalPosition(long ts) { 
    long timeDifference = (ts - initialTimeValue); 
    return timeDifference * timeScale; 
} 
  1. 此外,我们需要根据设备屏幕密度计算时间刻度。为了计算它,我们定义了一个默认时间刻度:
private static final float DEFAULT_TIME_SCALE = 0.0001f;  
  1. 在类构造函数中,我们根据屏幕密度调整了时间刻度:
final float screenDensity = getResources().getDisplayMetrics().density; 
timeScale = DEFAULT_TIME_SCALE * screenDensity;  

我们知道有很多不同屏幕尺寸和密度的安卓设备。这样做,而不是硬编码的像素尺寸,使渲染尽可能接近所有设备。

在这种方法的帮助下,我们可以轻松地循环半个小时,直到到达屏幕的末尾:

float x = getTimeHorizontalPosition(time) - frScrollX + getWidth() / 4.f; 
while (x < getWidth()) { 

    ... 

    time += 30 * 60 * 1000; // 30 minutes 
    x = getTimeHorizontalPosition(time) - frScrollX + getWidth() / 4.f; 
} 

通过将转换为毫秒的30分钟添加到时间变量中,我们以30分钟为单位增加水平标记。

我们也考虑了frScrollX的位置。当我们添加允许我们滚动的交互时,这个变量将被更新,但是我们将在本章后面看到。

渲染很简单:只要x坐标在屏幕内,我们就画一条垂直线;

if (x > 0) { 
    canvas.drawLine(x, 0, x, timebarHeight, paintTimeBar); 
} 

我们以HH:mm格式绘制时间,就在它旁边:

SimpleDateFormat dateFormatter = new SimpleDateFormat("HH:mm", Locale.US); 
String date = dateFormatter.format(new Date(time)); 
canvas.drawText(date, 
        x + programMargin, 
        (timebarHeight - timeBarTextBoundaries.height()) / 2.f 
                + timeBarTextBoundaries.height(), paintTimeBar); 

我们可以做的一个小的性能改进是存储字符串,这样我们就不必一次又一次地调用 format 方法,并且避免了昂贵的对象创建。我们可以通过创建一个 HashMap 来实现,该 HashMap 将一个长变量作为键并返回一个字符串:

String date = null; 
if (dateFormatted.containsKey(time)) { 
    date = dateFormatted.get(time); 
} else { 
    date = dateFormatter.format(new Date(time)); 
    dateFormatted.put(time, date); 
} 

如果我们已经有格式化的日期,我们会使用它,或者如果是第一次,我们会先格式化它,然后存储在 HashMap 上。

我们现在可以继续绘制当前的时间指示器。挺容易的;只是一个比单线略宽的竖框,所以我们用drawRect()代替drawLine():

private void drawCurrentTime(Canvas canvas, long currentTime) { 
    float currentTimePos = frChNameWidth +
    getTimeHorizontalPosition(currentTime) - frScrollX; 
    canvas.drawRect(currentTimePos - programMargin/2, 
            0, 
            currentTimePos + programMargin/2, 
            timebarHeight, 
            paintCurrentTime); 

    canvas.clipRect(frChNameWidth, 0, getWidth(), getHeight()); 
    canvas.drawRect(currentTimePos - programMargin/2, 
            timebarHeight, 
            currentTimePos + programMargin/2, 
            getHeight(), 
            paintCurrentTime); 
} 

由于我们已经有了getTimeHorizontalPosition方法,我们可以很容易地确定在哪里绘制当前时间指示器。当我们滚动电视节目时,我们将绘图分成两部分:一部分在时间条上画线,没有任何剪辑;另一条线从时间条的末端到屏幕的底部。在后一种情况下,我们应用剪辑仅将其绘制在电视节目的顶部。

为了更清楚地理解这一点,让我们看一下结果的截图:

在左侧,我们有代表频道的图标,在顶部是时间条,其余的是 EPG 的主体,有不同的电视节目。我们希望避免当前的红色时间线越过频道图标,所以我们应用了刚才提到的剪辑。

最后,我们可以实现整个 EPG 体的绘制。它比其他方法稍微复杂一点,所以让我们一步步来。首先,我们需要计算我们必须绘制的通道数量,以避免进行不必要的计算和试图在屏幕外绘制:

int startChannel = (int) (frScrollY / channelHeight); 
verticalOffset -= startChannel * channelHeight; 
int endChannel = startChannel + (int) ((getHeight() -  timebarHeight) / channelHeight) + 1; 
if (endChannel >= channelList.length) endChannel = channelList.length - 1; 

就像我们对时间刻度所做的那样,我们还定义了默认通道高度,并根据屏幕密度进行计算:

private static final int CHANNEL_HEIGHT = 80; 
... 
channelHeight = CHANNEL_HEIGHT * screenDensity; 

现在我们知道了需要绘制的初始通道和结束通道,我们可以勾勒出绘制循环:

canvas.save(); 
canvas.clipRect(0, timebarHeight, getWidth(), getHeight()); 

for (int i = startChannel; i <= endChannel; i++) { 
    float channelTop = (i - startChannel) * channelHeight -
    verticalOffset +
    timebarHeight; 
    float channelBottom = channelTop + channelHeight; 

    ... 

} 

canvas.drawLine(frChNameWidth, timebarHeight, frChNameWidth, getHeight(), paintChannelText); 
canvas.restore(); 

我们将多次修改canvas剪辑,所以让我们在方法开始时保存它,在结束时恢复它。这样我们就不会影响在此之后完成的任何其他绘图方法。在循环中,对于每个通道,我们还计算channelTopchannelBottom值,因为它们在以后绘制时会很方便。这些值表示我们正在绘制的通道顶部和底部的垂直坐标。

现在让我们为每个频道画一个图标,如果我们没有它,首先从互联网上请求它。我们将使用Picasso来管理互联网请求,但是我们可以使用任何其他库:

if (channelList[i].getIcon() != null) { 
    float iconMargin = (channelHeight -
    channelList[i].getIcon().getHeight()) / 2;

    canvas.drawBitmap(channelList[i].getIcon(), iconMargin, channelTop
    + iconMargin, null); 

} else { 
    if (channelTargets[i] == null) { 
        channelTargets[i] = new ChannelIconTarget(channelList[i]); 
    } 

    Picasso.with(context) 
            .load(channelList[i] 
            .getIconUrl()) 
            .into(channelTargets[i]); 
} 

有关于毕加索的信息在: http://square.github.io/picasso/

此外,对于每个频道,我们需要绘制屏幕内的电视节目。让我们再次使用之前创建的方法将时间戳转换为屏幕坐标:

for (int j = 0; j < programs.size(); j++) { 
    Program program = programs.get(j); 

    long st = program.getStartTime(); 
    long et = program.getEndTime(); 

    float programStartX = getTimeHorizontalPosition(st); 
    float programEndX = getTimeHorizontalPosition(et); 

    if (programStartX - frScrollX > getWidth()) break; 
    if (programEndX - frScrollX >= 0) { 

        ... 

    } 
} 

这里,我们从程序开始和结束时间得到程序开始和结束位置。如果开始位置超出屏幕宽度,我们可以停止检查更多电视节目,因为它们都在屏幕之外,假设电视节目按时间升序排序。此外,如果结束位置小于 0,我们可以跳过这个特定的电视节目,因为它也将被绘制在屏幕之外。

实际绘图相当简单;我们正在使用一个drawRoundRect作为电视节目背景,我们正在以它为中心绘制节目名称。我们还裁剪了该区域,以防名称比电视节目框长:

canvas.drawRoundRect(horizontalOffset + programMargin + programStartX, 
       channelTop + programMargin, 
       horizontalOffset - programMargin + programEndX, 
       channelBottom - programMargin, 
       programMargin, 
       programMargin, 
       paintProgram); 

canvas.save(); 
canvas.clipRect(horizontalOffset + programMargin * 2 + programStartX, 
       channelTop + programMargin, 
       horizontalOffset - programMargin * 2 + programEndX, 
       channelBottom - programMargin); 

paintProgramText.getTextBounds(program.getName(), 0, program.getName().length(), textBoundaries); 
float textPosition = channelTop + textBoundaries.height() + ((channelHeight - programMargin * 2) - textBoundaries.height()) / 2; 
canvas.drawText(program.getName(), 
           horizontalOffset + programMargin * 2 + programStartX, 
           textPosition, 
           paintProgramText); 
canvas.restore(); 

我们还添加了一个小检查来查看电视节目当前是否正在播放。如果当前时间大于或等于节目开始时间,小于其结束时间,我们可以断定电视节目当前正在播放,并以高亮颜色呈现。

if (st <= currentTime && et > currentTime) { 
    paintProgram.setColor(HIGHLIGHTED_PROGRAM_COLOR); 
    paintProgramText.setColor(Color.BLACK); 
} else { 
    paintProgram.setColor(PROGRAM_COLOR); 
    paintProgramText.setColor(Color.WHITE); 
} 

现在我们来添加动画循环。对于这个例子,我们选择了固定的时间步长机制。我们将只设置滚动变量的动画,包括水平和垂直,以及屏幕通道部分的移动:

private void animateLogic() { 
    long currentTime = SystemClock.elapsedRealtime(); 
    accTime += currentTime - timeStart; 
    timeStart = currentTime; 

    while (accTime > TIME_THRESHOLD) { 
        scrollX += (scrollXTarget - scrollX) / 4.f; 
        scrollY += (scrollYTarget - scrollY) / 4.f; 
        chNameWidth += (chNameWidthTarget - chNameWidth) / 4.f; 
        accTime -= TIME_THRESHOLD; 
    } 

    float factor = ((float) accTime) / TIME_THRESHOLD; 
    float nextScrollX = scrollX + (scrollXTarget - scrollX) / 4.f; 
    float nextScrollY = scrollY + (scrollYTarget - scrollY) / 4.f; 
    float nextChNameWidth = chNameWidth + (chNameWidthTarget -
                            chNameWidth) / 4.f; 

    frScrollX = scrollX * (1.f - factor) + nextScrollX * factor; 
    frScrollY = scrollY * (1.f - factor) + nextScrollY * factor; 
    frChNameWidth = chNameWidth * (1.f - factor) + nextChNameWidth *
    factor; 
} 

在稍后的渲染和计算中,我们将使用frScrollXfrScrollYfrChNameWidth变量,这些变量包含当前逻辑刻度和后续逻辑刻度之间的小数部分。

我们将在下一节中看到如何在 EPG 中添加交互,但是我们刚刚介绍了通道部分的移动。现在,我们只是将每个频道渲染为一个图标,但是,为了获得更多信息,我们添加了一个切换,使频道框(我们当前拥有图标的位置)变得更大,并在图标旁边绘制频道标题。

我们已经创建了一个布尔开关来跟踪我们正在渲染的状态,并在需要时绘制通道名称:

if (!shortChannelMode) { 
    paintChannelText.getTextBounds(channelList[i].getName(), 
            0, 
            channelList[i].getName().length(), 
            textBoundaries); 

    canvas.drawText(channelList[i].getName(), 
            channelHeight - programMargin * 2, 
            (channelHeight - textBoundaries.height()) / 2 +
             textBoundaries.height() + channelTop, 
            paintChannelText); 
} 

切换非常简单,因为它只是将通道盒宽度目标更改为channelHeight,所以它将具有方形尺寸,或者在绘制文本时是channelHeight的两倍。动画循环将负责制作变量的动画:

if (shortChannelMode) { 
    chNameWidthTarget = channelHeight * 2; 
    shortChannelMode = false; 
} else { 
    chNameWidthTarget = channelHeight; 
    shortChannelMode = true; 
}  

互动

到目前为止,它并没有真正的用处,因为我们无法与之交互。要添加交互,我们需要覆盖视图中的onTouchEvent()方法,正如我们在前面章节中看到的。

在我们自己对 onTouchEvent 的实现中,我们主要对ACTION_DOWNACTION_UPACTION_MOVE事件感兴趣。让我们看看我们已经完成的实现:

private float dragX; 
private float dragY; 
private boolean dragged; 

... 

@Override 
public boolean onTouchEvent(MotionEvent event) { 

    switch(event.getAction()) { 
        case MotionEvent.ACTION_DOWN: 
            dragX = event.getX(); 
            dragY = event.getY(); 

            getParent().requestDisallowInterceptTouchEvent(true); 
            dragged = false; 
            return true; 

        case MotionEvent.ACTION_UP: 
            if (!dragged) { 
                // touching inside the channel area, will toggle
                   large/short channels 
                if (event.getX() < frChNameWidth) { 
                    switchNameWidth = true; 
                    invalidate(); 
                } 
            } 

            getParent().requestDisallowInterceptTouchEvent(false); 
            return true; 

        case MotionEvent.ACTION_MOVE: 
            float newX = event.getX(); 
            float newY = event.getY(); 

            scrollScreen(dragX - newX, dragY - newY); 

            dragX = newX; 
            dragY = newY; 
            dragged = true; 
            return true; 
        default: 
            return false; 
    } 
} 

这个方法不包含太多逻辑;它只是检查我们是否在屏幕上拖动,用上一个事件的拖动量δ调用scrollScreen,并且,在我们没有拖动而只是在通道盒上按下的情况下,触发切换使通道盒变大或变小。

scrollScreen方法只是更新scrollXTargetscrollYTarget并检查其边界:

private void scrollScreen(float dx, float dy) { 
    scrollXTarget += dx; 
    scrollYTarget += dy; 

    if (scrollXTarget < -chNameWidth) scrollXTarget = -chNameWidth; 
    if (scrollYTarget < 0) scrollYTarget = 0; 

    float maxHeight = channelList.length * channelHeight - getHeight()
    + 1 + timebarHeight; 
    if (scrollYTarget > maxHeight) scrollYTarget = maxHeight; 

    invalidate(); 
} 

另外,调用invalidate来触发重绘事件非常重要。在onDraw()事件本身,我们检查所有动画是否完成,如果需要,触发更多重绘事件:

if (missingAnimations()) invalidate(); 

missingAnimations的实际实现相当简单:

private static final float ANIM_THRESHOLD = 0.01f; 

private boolean missingAnimations() { 
    if (Math.abs(scrollXTarget - scrollX) > ANIM_THRESHOLD) 
    return true;

if (Math.abs(scrollYTarget - scrollY) > ANIM_THRESHOLD)
    return true;

if (Math.abs(chNameWidthTarget - chNameWidth) > ANIM_THRESHOLD)
    return true;

return false;
} 

我们只是检查所有属性,如果它们与目标值的差异小于预定义的阈值,这些属性可以被动画化。如果只有一个大于这个阈值,我们需要触发更多的重绘事件和动画周期。

变焦

由于我们为每个电视节目渲染一个框,并且它的大小直接由电视节目持续时间决定,因此电视节目标题可能会比它的渲染框大。在这种情况下,我们可能想要阅读标题的更多部分,或者,在其他时候,我们可能想要压缩一些东西,这样我们就可以对当天晚些时候将在电视上播放的内容有一个整体的了解。

为了解决这个问题,我们可以通过在我们的 EPG 小部件上捏一下我们的设备屏幕来实现缩放机制。我们可以将这种缩放直接应用于timeScale变量,并且,由于我们在所有计算中都使用了它,所以它将保持一切同步:

scaleDetector = new ScaleGestureDetector(context,  
    new ScaleGestureDetector.SimpleOnScaleGestureListener() {  

    ... 

    }); 

为了简化它,让我们使用SimpleOnScaleGestureListener,它允许我们只覆盖我们想要使用的方法。

现在,我们需要修改onTouchEventscaleDetector实例也处理事件:

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    scaleDetector.onTouchEvent(event); 

    if (zooming) { 
        zooming = false; 
        return true; 
    } 

    ... 

} 

我们还添加了一个检查,看看我们是否正在缩放。我们将在ScaleDetector实现中更新这个变量,但概念是避免滚动视图,或者处理拖动事件,如果我们已经在缩放的话。

现在让我们执行ScaleDetector:

scaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() { 
    private long focusTime; 
    private float scrollCorrection = 0.f; 
    @Override 
    public boolean onScaleBegin(ScaleGestureDetector detector) { 
        zooming = true; 
        focusTime = getHorizontalPositionTime(scrollXTarget +
        detector.getFocusX() - frChNameWidth); 
        scrollCorrection = getTimeHorizontalPosition((focusTime)) -
        scrollXTarget; 
        return true; 
    } 

    public boolean onScale(ScaleGestureDetector detector) { 
        timeScale *= detector.getScaleFactor(); 
        timeScale = Math.max(DEFAULT_TIME_SCALE * screenDensity / 2,  
                        Math.min(timeScale, DEFAULT_TIME_SCALE *
                        screenDensity * 4)); 

        // correct scroll position otherwise will move too much when
           zooming 
        float current = getTimeHorizontalPosition((focusTime)) -
        scrollXTarget; 
        float scrollDifference = current - scrollCorrection; 
        scrollXTarget += scrollDifference; 
        zooming = true; 

        invalidate(); 
        return true; 
    } 

    @Override 
    public void onScaleEnd(ScaleGestureDetector detector) { 
        zooming = true; 
    } 
}); 

我们基本上在做两件不同的事情。首先,我们将timeScale变量从默认值的一半调整为默认值的四倍:

timeScale *= detector.getScaleFactor(); 
timeScale = Math.max(DEFAULT_TIME_SCALE * screenDensity / 2,  
                Math.min(timeScale, DEFAULT_TIME_SCALE * screenDensity
                * 4)); 

此外,我们调整滚动位置,以避免缩放时出现不愉快的效果。通过调整滚动位置,我们试图将收缩的焦点保持在同一位置,即使在放大或缩小后也是如此。

float current = getTimeHorizontalPosition((focusTime)) - scrollXTarget; 
float scrollDifference = current - scrollCorrection; 
scrollXTarget += scrollDifference; 

有关ScaleDetector和手势的更多信息,请查看安卓官方文档。

配置和扩展

如果想创建一个可供多人使用的自定义视图,它需要是可定制的。EPG 也不例外。在我们的初始实现中,我们对一些颜色和值进行了硬编码,但是让我们看看如何扩展这些功能并使我们的 EPG 可定制。

使其可配置

在本书的最初几章中,我们介绍了如何添加参数,以及如何轻松地自定义我们的自定义视图。遵循同样的原则,我们创建了一个attrs.xml文件,其中包含所有可定制的参数:

<?xml version="1.0" encoding="utf-8"?> 
<resources> 
    <declare-styleable name="EPG"> 
        <attr name="backgroundColor" format="color"/> 
        <attr name="programColor" format="color"/> 
        <attr name="highlightedProgramColor" format="color"/> 
        <attr name="currentTimeColor" format="color"/> 
        <attr name="channelTextColor" format="color"/> 
        <attr name="programTextColor" format="color"/> 
        <attr name="highlightedProgramTextColor" format="color"/> 
        <attr name="timeBarColor" format="color"/> 

        <attr name="channelHeight" format="float"/> 
        <attr name="programMargin" format="float"/> 
        <attr name="timebarHeight" format="float"/> 
    </declare-styleable> 
</resources> 

还有许多其他变量可以作为参数添加,但是从自定义视图外观的角度来看,这些是主要的自定义。

此外,在我们的类构造函数中,我们添加了读取和解析这些参数的代码。在它们不存在的情况下,我们会默认为之前硬编码的值。

TypedArray ta = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EPG, 0, 0); 
try { 
    backgroundColor = ta.getColor(R.styleable.EPG_backgroundColor,
    BACKGROUND_COLOR); 
    paintChannelText.setColor(ta.getColor(R.styleable.EPG_channelTextColor
                          Color.WHITE)); 
    paintCurrentTime.setColor(ta.getColor(R.styleable.EPG_currentTimeColor,
                          CURRENT_TIME_COLOR)); 
    paintTimeBar.setColor(ta.getColor(R.styleable.EPG_timeBarColor,
                          Color.WHITE)); 

    highlightedProgramColor =
    ta.getColor(R.styleable.EPG_highlightedProgramColor,
        HIGHLIGHTED_PROGRAM_COLOR);

    programColor = ta.getColor(R.styleable.EPG_programColor,
    PROGRAM_COLOR);

    channelHeight = ta.getFloat(R.styleable.EPG_channelHeight,
    CHANNEL_HEIGHT) * screenDensity;

    programMargin = ta.getFloat(R.styleable.EPG_programMargin,
    PROGRAM_MARGIN) * screenDensity;

    timebarHeight = ta.getFloat(R.styleable.EPG_timebarHeight,
    TIMEBAR_HEIGHT) * screenDensity;

    programTextColor = ta.getColor(R.styleable.EPG_programTextColor,
    Color.WHITE);

    highlightedProgramTextColor =
    ta.getColor(R.styleable.EPG_highlightedProgramTextColor,
        Color.BLACK);
} finally { 
    ta.recycle(); 
} 

为了让任何试图定制它的人更简单、更清晰,我们可以做一个小小的改变。让我们将直接映射到像素大小的参数重新定义为尺寸,而不是浮动:

<attr name="channelHeight" format="dimension"/> 
<attr name="programMargin" format="dimension"/> 
<attr name="timebarHeight" format="dimension"/> 

将解析代码更新为以下内容:

channelHeight = ta.getDimension(R.styleable.EPG_channelHeight, 
        CHANNEL_HEIGHT * screenDensity); 

programMargin = ta.getDimension(R.styleable.EPG_programMargin, 
        PROGRAM_MARGIN * screenDensity); 

timebarHeight = ta.getDimension(R.styleable.EPG_timebarHeight, 
        TIMEBAR_HEIGHT * screenDensity); 

通过使用getDimension而不是getFloat,它会自动将设置为密度像素的尺寸转换为实际像素。它不会进行到默认值的转换,所以我们仍然需要自己进行screenDensity乘法。

最后,我们需要在activity_main.xml布局文件中添加这些配置:

<?xml version="1.0" encoding="utf-8"?> 
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" 
    xmlns:tools="http://schemas.android.com/tools" 
    android:layout_width="match_parent" 
    android:layout_height="match_parent" xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context="com.rrafols.packt.epg.MainActivity"> 

    <com.rrafols.packt.epg.EPG 
        android:id="@+id/epg_view" 
        android:layout_width="match_parent" 
        android:layout_height="match_parent" 
        app:channelHeight="80dp"
        app:highlightedProgramColor="#ffffdd20"
        app:highlightedProgramTextColor="#ff000000"/>
</LinearLayout>  

我们可以在下面的截图中看到这些变化的结果:

实现回调

EPG 的另一个我们还没有涉及到的关键功能是在点击电视节目时实际做一些事情的能力。如果我们想用我们的 EPG 做一些有用的事情,而不仅仅是显示即将到来的标题,我们必须实现这个功能。

这个实现非常简单,它将处理外部监听器或回调的逻辑。修改源代码以在 EPG 本身上实现一些自定义行为也非常容易。

首先,我们用一个方法在 EPG 类中创建一个新的接口:

interface EPGCallback { 
    void programClicked(Channel channel, Program program); 
} 

每当我们点击一个电视节目就会调用这个方法,无论是谁实现这个回调都会得到Channel和电视Program

现在,让我们修改onTouchEvent()方法来处理这个新功能:

if (event.getX() < frChNameWidth) { 

    ... 

} else { 
    clickProgram(event.getX(), event.getY()); 
} 

在我们之前的代码中,我们只在点击屏幕的频道区域时进行检查。现在我们可以使用另一个区域来检测我们是否点击了电视节目。

现在我们来实现clickProgram()方法:

private void clickProgram(float x, float y) { 
    long ts = getHorizontalPositionTime(scrollXTarget + x -
    frChNameWidth); 
    int channel = (int) ((y + frScrollY - timebarHeight) / 
    channelHeight); 

    ArrayList<Program> programs = channelList[channel].getPrograms(); 
    for (int i = 0; i < programs.size(); i++) { 
        Program pr = programs.get(i); 
        if (ts >= pr.getStartTime() && ts < pr.getEndTime()) { 
            if (callback != null) { 
                callback.programClicked(channelList[channel], pr); 
            } 
            break; 
        } 
    } 
}  

我们首先将用户点击的水平位置转换为时间戳,通过触摸事件的垂直位置,我们可以确定频道。有了通道和时间戳,我们就可以检查用户点击了哪个程序,并使用这些信息调用回调。

在 GitHub 示例中,我们添加了一个虚拟监听器,它将只记录被点击的频道和程序:

@Override 
protected void onCreate(Bundle savedInstanceState) { 
    super.onCreate(savedInstanceState); 
    setContentView(R.layout.activity_main); 

    EPG epg = (EPG) findViewById(R.id.epg_view); 
    epg.setCallback(new EPG.EPGCallback() { 
        @Override 
        public void programClicked(Channel channel, Program program) { 
            Log.d("EPG", "program clicked: " + program.getName() + "
            channel: " + channel.getName()); 
        } 
    }); 

    populateDummyChannelList(epg); 
} 

这个活动onCreate中还有一个populateDummyChannelList()方法。这种方法只会填充随机频道和电视节目数据,如果连接到真正的 EPG 数据提供商,则应该删除。

整个例子可以在 GitHub 存储库的Example33-EPG文件夹中找到。

摘要

在这一章中,我们已经看到了如何构建一个具有许多功能的简单 EPG,但是我们可能已经让许多其他的没有实现。例如,我们的电视节目渲染相当简单,我们可以在电视节目框中添加更多信息,如持续时间、开始时间和结束时间,甚至直接在那里显示电视节目描述。

请随意获取 GitHub 存储库中的内容并使用它,添加新的定制或功能,并根据您的需求进行调整。

我们并没有具体谈论性能那么多,但是我们已经尽可能地减少了我们的onDraw方法和它调用的方法中的分配量,并且我们已经尽可能地减少了我们在屏幕上绘制的内容,甚至不处理那些会超出屏幕边界的元素。

如果我们想让我们的定制视图(在这种情况下是 EPG 视图)更快、更灵敏、更能适应更多频道和电视节目,那么考虑这些细节至关重要。

在下一章中,我们将构建另一个复杂的定制视图,我们可以使用它在我们的安卓应用上绘制图形。