Skip to content

实战篇 3:天气页面样式布局开发

先写一个 icon 组件

「新鲜天气」项目中,用到的 icon 比较多,比如天气图标、心情签到的表情,都是来自 icon 组件,本小节介绍下如何自定义个 icon 组件。

我们项目的自定义组件放在 client/components 目录下,首先在其目录下创建 icon 目录,创建组件的页面、样式和 JS 文件:

client/components
└── icon
    ├── index.js
    ├── index.json
    ├── index.scss
    ├── index.wxml
    └── weather.ttf

组件需要在自己的页面配置文件(page.json,即 index.json)中声明自己是一个组件:

// index.json
{
  "component": true
}

编写组件代码

icon 组件的 WXML 部分代码很简单:

<!--index.wxml-->
<text class="icon icon-{{ type }}"></text>

笔者定义了一个 icon 的类型字段,该字段由使用方传入,所以对应 JS 的写法为:

// index.js
Component({
  properties: {
    type: {
      type: String,
      value: ''
    }
  }
});

JS 中使用了 Component 构造器,调用 Component 构造器时可以指定组件的属性、数据、方法等。上面代码中定义了组件可以接受的 propertiestypetype 是一个字符串类型的值,默认值是空字符串。

跟所有的 icon 样式写法一样,笔者通过图标共有的 class icon 定义了统一的样式,包括字体、大小等:

/* index.scss */
@font-face {
  font-family: "weather";
  src: url("./weather.ttf") format("truetype");
}

.icon {
  font-style: normal;
  -webkit-font-smoothing: antialiased;
  -webkit-text-stroke-width: .4rpx;
  -moz-osx-font-smoothing: grayscale;
}

.icon::after,
.icon::before {
  font-family: weather !important;
}

然后通过 .icon-{{type}} 定义了不同的图标对应 class 的 content:

/* index.scss */
.icon.icon-xiaolian:before {
  content: "\e60f";
}

.icon.icon-shidu:before {
  content: "\e610";
}

.icon.icon-zhongyu:before {
  content: "\e611";
}

字体文件的引用问题

在小程序内,不支持使用 webfont 的 @font-face 引入本地的 ttf 等文件,这时候需要使用线上地址或者 base64

在新鲜天气的开发中,笔者使用了 Gulp 直接将 webfont 转换成 base64 引入,这样写代码的时候就不需要关注这些事情了,具体做法为:

const rename = require('gulp-rename')
const postcss = require('gulp-postcss')
const pxtorpx = require('postcss-px2rpx')
const base64 = require('postcss-font-base64')
const combiner = require('stream-combiner2')

gulp.task('wxss', () => {
  const combined = combiner.obj([
    gulp.src(`${src}/**/*.{wxss,scss}`),
    sass().on('error', sass.logError),
    postcss([pxtorpx(), base64()]),
    rename((path) => (path.extname = '.wxss')),
    gulp.dest(dist)
  ])

  combined.on('error', handleError)
})

除了字体以外,图片类静态资源的引入可以使用本地资源相对路径、base64 和线上地址,如果是线上地址,则必须是以 https 开头的协议。

在项目中使用自定义组件

在需要使用自定义组件的页面配置文件 page.json 中添加 usingComponents 字段:

"usingComponents": {
  "icon": "../../components/icon/index"
}

其中,icon 就是我们自定义的标签名称,后面的值则是相对于页面文件、icon 组件对应的路径。

这样引入后,在页面中就可以直接通过 icon 标签来使用自定义的 icon 组件了:

<!--定位icon-->
<icon type="dingwei" />
<!--天气icon-->
<icon type="{{ today.icon }}" class="logo"></icon>

也可以使用 CSS(WXSS)来控制它的样式:

icon {
  float: right;
  font-size: 44rpx;
  height: 44rpx;
  width: 44rpx;
}

如何编辑字体:icon 组件使用的字体是来自 iconfont.cn,然后在百度的字体编辑器中进行编辑。

天气预报页面

天气预报是小程序的第一个页面,首先在 app.json 中的 pages 处添加入口:

"pages": ["pages/weather/index"]

如果有多个页面,当前开发的页面可以放到 pages 数组的最前面,这样小程序的默认页面就是当前开发的页面,方便实时开发和查看效果。

添加完入口之后,在 client/pages 目录下创建 weather 文件夹,目录结构如下:

pages
└── weather
    ├── index.js
    ├── index.json
    ├── index.scss
    ├── index.wxml
    └── index.wxs

因为我们要使用上文完成的 icon 组件,所以在 index.json 中添加 usingComponents 字段,天气页面还支持下拉刷新,于是最终版本的 index.json 配置如下:

//index.json
{
  "enablePullDownRefresh": true,
  "usingComponents": {
    "icon": "../../components/icon/index"
  }
}

页面整体框架

首先我们来完成页面的整体框架 WXML 代码,页面整体包裹在 .wrapper 中,页面使用 .container 来区分不同的页面模块,模块之间通过 .containermargin-bottom 属性隔开。

<!--weather/index.wxml-->
<view class="wrapper" style="background: url({{backgroundImage}}) center -178rpx / 100% no-repeat {{backgroundColor}};">

  <view class="container" id="canvas-wrapper">
    <view class="now">
      <!--当前实时天气和空气质量-->
    </view>
    <view class="two-days">
      <!--今明两天天气-->
    </view>
  </view>
  <view class="weather" style="background-color: {{backgroundColor}}">
    <view class="container">
      <!--24 小时天气-->
    </view>
    <view class="container">
      <view class="week">
        <!--七天天气-->
      </view>
    </view>
    <view class="container">
      <view class="life-style">
         <!--生活指数-->
      </view>
    </view>
  </view>
</view>
// weather/index.scss
// 定义 container 间隔
$grid-margin: 20rpx;
.container {
  margin-bottom: $grid-margin;
  max-width: 750rpx;
  box-sizing: border-box;
  color: #fff;
}

.wrapper 的背景图片和 .weather 的背景色都是根据天气情况更换的,需要根据天气数据赋值,这里笔者设置了默认值:

Page({
  data: {
    // 页面数据
    backgroundImage: '../../images/cloud.jpg',
    backgroundColor: '#62aadc'
    ...

实时天气部分页面布局

天气页面的「实时天气」部分页面布局相对复杂,最终效果如图所示。

首先是当前天气部分页面结构:

<!--weather/index.wxml-->
<view class="now">
  <view class="location" bindtap="chooseLocation">
    <icon type="dingwei" />
    <text>{{ address }}</text>
  </view>
  <view class="air-quality" wx:if="{{air.aqi}}">
    <text class="circle" style="background: {{ air.color }}"></text>
    <text class="value">{{ air.name }} {{ air.aqi }}</text>
  </view>
  <view class="now-weather">
    <view class="temp">
      <text>{{ current.temp }}</text>
      <text class="degree">°</text>
    </view>
    <view class="cur-weather">
      <view class="inline">
        <icon type="{{ current.icon }}"></icon>
        <text>{{ current.weather }}</text>
      </view>
      <view class="inline today">
        <text class="item">{{ utils.humidity(current.humidity) }}</text>
        <text class="item">{{ utils.wind(current.wind, current.windLevel) }}</text>
      </view>
    </view>
    <view class="tips" wx:if="{{tips}}">
      <text>{{tips}}</text>
    </view>
  </view>
</view>

上面页面需要的 AppData 示例数据为:

"air": {
  "status": 0,
  "aqi": "77",
  "color": "#00cf9a",
  "name": "良"
},
"current": {
  "backgroundImage": "https://tianqi-1d3bf9.tcb.qcloud.la/bg/day/overcast.jpg",
  "backgroundColor": "#5c7a93",
  "temp": "35",
  "wind": "南风",
  "windLevel": "1",
  "weather": "阴",
  "humidity": "73",
  "icon": "yin",
  "ts": "2018-08-12 14:54"
},

上面的 WXML 中,笔者还使用了 utils 的两个方法。utils的方法来自于index.wxs,要使用index.wxs需要在页面的顶部引入它:

<wxs src="./index.wxs" module="utils"></wxs>

WXS 相对 JS 来说语法更加受限,但是因为 WXML 的「双括号」数据绑定中对表达式的支持不够完善,我们在小程序开发中,可以使用 WXS 来增强 WXML 的表达式,其中 humiditywind 的 WXS 代码如下:

// weather/index.wxs
module.exports = {
  // 湿度处理
  humidity: function(h) {
    if (h) {
      return '湿度 ' + h + '%'
    }
    return h
  },
  // 根据风的 code 和风力输出文案
  wind: function(code, level) {
    if (!code) {
      return '无风'
    }
    if (level) {
      level = level.toString().split('-')
      level = level[level.length - 1]
      return code + ' ' + level + '级'
    }
    return code
  }
}

实时天气模块中,今明两天的 WXML 结构如下:

<!--weather/index.wxml-->
<!--今明两天天气数据-->
<view class="two-days">
  <view class="item">
    <view class="top">
      <text class="date">今天</text>
      <text class="temp">{{ today.temp }}</text>
    </view>
    <view class="bottom">
      <text>{{ today.weather }}</text>
      <icon type="{{ today.icon }}" class="logo"></icon>
    </view>
  </view>
  <view class="item">
    <view class="top">
      <text class="date">明天</text>
      <text class="temp">{{ tomorrow.temp }}</text>
    </view>
    <view class="bottom">
      <text>{{ tomorrow.weather }}</text>
      <icon type="{{ tomorrow.icon }}" class="logo"></icon>
    </view>
  </view>
</view>

由此可见,需要的 AppData 示例数据结构是:

"today": {
  "temp": "24/30°",
  "icon": "leizhenyu",
  "weather": "雷阵雨"
},
"tomorrow": {
  "temp": "24/30°",
  "icon": "leizhenyu",
  "weather": "雷阵雨"
},

需要说明的是,在今明两天天气布局中,笔者使用了 flex 布局,flex 布局使得小程序的页面布局更灵活,结构更明晰:

// weather/index.scss
@mixin flex-row {
  display: flex;
  flex-direction: row;
}
.today {
  @include flex-row;
  .item {
    display: block;
    flex: 1;
    padding-right: 16rpx;
    margin: 0 16rpx 0 0;
    border-right: 2rpx solid rgba(255, 255, 255, .4);
  }
}

屏幕适配:自定义导航样式

为了UI效果,笔者使用了自定义导航条样式,即在app.json中增加配置:

"window": {
  "navigationStyle": "custom"
},

经过上面配置,就没有导航条了,整个界面直接是天气预报页面的背景图,现在遇见了小程序的屏幕适配问题,笔者界面设计是定位地址文案部分跟小程序的胶囊操作区域对齐,如下图所示:

但是如果只是简单的使用 rpx,在 iPhone 6 的视觉稿(具体原因见基础篇 1:小程序开发基础知识)标准下实现:

<!--pages/weather/index.wxml-->
<view class="container" id="canvas-wrapper" style="padding-top: 64rpx">

上面直接使用 rpx 来布局,由于不同手机的屏幕尺寸不同,实际产生的效果是:

要做好屏幕适配,需要用到 rpx 的基础知识和wx.getSystemInfo()方法。

我们通过学习基础知识了解,rpx 是按照屏幕宽度来定义的,不管屏幕多宽,屏幕宽度始终定义为 750rpx,宽度不同,则 1rpx 实际宽度不同,如果用这个不确定的 rpx 来对高度做统一是实现不了的,所以我们固定使用padding-top: 64rpx实际根据不同屏幕它是高度不一致的。手机都有状态栏(statusBar),状态栏高度也是不同手机不同的尺寸,所以,最终在不同的手机会出现上面图片的效果。

要解决这个屏幕适配问题,即确定一个固定的 padding-top 值,需要将状态栏高度和 rpx 的实际对应 px 值进行统一计算。

首先,在页面结构中,使用 {{paddingTop}} 来表示 padding-top 值。

<!--pages/weather/index.wxml-->
<view class="container" id="canvas-wrapper" style="padding-top: {{paddingTop}}px">

这个值是计算之后的 px 值,所以单位是 px!这个值在 iPhone 6 手机中是32px(iPhone 6 屏幕宽度为375px,所以750rpx = 375px)。下面我们需要获取系统的状态栏高度(statusBarHeight),可以使用wx.getSystemInfo()或者它的同步方法wx.getSystemInfoSync() 获取:

//pages/weather/index.js
wx.getSystemInfo({
  success: (res) => {
    // 状态栏高度和屏幕宽度,单位都是px
    console.log(res.statusBarHeight, res.windowWidth)
  }
})

经过获取状态栏高度发现,iPhone 6 手机的状态栏高度为20px,所以计算出差值为12px32px - 20px)。下面我们只需要将状态栏高度获取之后,加上12px即可。所以最终paddingTop计算代码是:

//pages/weather/index.js
wx.getSystemInfo({
  success: (res) => {
    // 状态栏高度和屏幕宽度
    // console.log(res.statusBarHeight, res.windowWidth)
    // console.log(scale * res.statusBarHeight*2+24)
    this.setData({
      paddingTop: res.statusBarHeight+12
    })
  }
})

Tips:rpx 并不是「万能油」,根据实际情况也可以使用 px 来解决实际问题。

WXML 的循环:24小时、一周天气和生活指数

天气数据中,24 小时和一周天气都是由数组组成:

// 24小时天气数据
"hourlyData": [
  {
    "temp": "29",
    "time": "16:00",
    "weather": "雷阵雨",
    "icon": "leizhenyu"
  }
  // ...
],
// 一周天气数据
"weeklyData": [
  {
    "day": "雷阵雨",
    "dayIcon": "leizhenyu",
    "dayWind": "南风",
    "dayWindLevel": "1-2",
    "maxTemp": "30",
    "minTemp": "24",
    "night": "中雨",
    "nightIcon": "zhenyuye",
    "nightWind": "南风",
    "nightWindLevel": "1-2",
    "time": 1534032000000
  }
  // ...
],
// 生活指数
"lifeStyle": [
  {
    "name": "舒适度", // 指数名称
    "icon": "guominzhishu", // 指数对应的icon图标type
    "info": "较不舒适", // 指数数值
    // 指数的详情
    "detail": "白天虽然有雨,但仍无法削弱较高气温带来的暑意,同时降雨造成湿度加大会您感到有些闷热,不很舒适。"
  }
  // ...
]

对于这些数组结构,我们在写页面的时候可以使用 WXML 的循环语句 wx:for 来输出 WXML:

<!--weather/index.wxml-->
<!--24小时天气-->
<scroll-view scroll-x class="hourly">
  <view class="scrollX">
    <view class="item" wx:for="{{hourlyData}}">
      <text class="time">{{ item.time }}</text>
      <icon type="{{item.icon}}" class="icon"></icon>
      <text class="temp">{{item.temp}}°</text>
    </view>
  </view>
</scroll-view>
<!--一周天气数据-->
<view class="week">
  <view class="week-weather">
    <view class="item" wx:for="{{weeklyData}}">
      <view class="day">{{ utils.formatWeeklyDate(index) }}</view>
      <view class="date">{{ utils.formatDate(item.time) }}</view>
      <view class="daytime">
        <view class="wt">{{item.day}}</view>
        <icon type="{{item.dayIcon}}" class="img"></icon>
      </view>
      <view class="night">
        <icon type="{{item.nightIcon}}" class="img"></icon>
        <view class="wt">{{item.night}}</view>
      </view>
      <view class="wind">{{ utils.wind(item.nightWind) }}</view>
      <view class="wind" wx:if="{{item.nightWind}}">{{ utils.windLevel(item.nightWindLevel) }}</view>
      <view class="wind" wx:else></view>
    </view>
  </view>
<!--生活指数-->
<view class="life-style">
    <view class="item" wx:for="{{lifeStyle}}" data-name="{{item.name}}" data-detail="{{item.detail}}" bindtap="indexDetail">
      <view class="title">
        <icon type="{{item.icon}}"></icon>
        {{item.name}}
      </view>
      <view class="content">{{item.info}}</view>
    </view>
</view>

这里需要特别说下「24小时天气」和「生活指数」。对于「24小时天气」,笔者使用了 scroll-view 组件 + flex 布局,根据数组数据的长度(和风天气免费 API 只能获取间隔 3 个小时共 8 个小时天气)来计算 scroll-view 的整体宽度,然后按照等比例划分:

// weather/index.scss
@mixin flex-column {
  display: flex;
  flex-direction: column;
}
// hourly
.hourly {
  .scrollX {
    position: relative;
    // 总长度,116*8
    width: 928rpx;
    padding: 40rpx 0;
    height: 150rpx;
  }
  .item {
    @include flex-column;
    width: 116rpx;
  }
}

「生活指数」布局是上下两行:

flex 布局中使用横向(flex-row)布局,要达到 4x2 的布局效果,需要将子项设置为25%宽度,并且设置父容器 flex-wrap: wrap

// weather/index.scss
.life-style {
  @include flex-row;
  flex-wrap: wrap;

  .item {
    text-align: center;
    width: 25%;
    height: 188rpx;
    border-right: 2rpx solid rgba(255, 255, 255, .1);
    border-bottom: 2rpx solid rgba(255, 255, 255, .1);
    box-sizing: border-box;
    padding: 50rpx 0 0;
  }
}

小结

本节主要从整体上介绍了「新鲜天气」天气预报页面的布局实现,用到了 WXS 来增强 WXML 的数据绑定表达式,使用了多种 flex 布局效果,对于数组型数据,使用了 WXML 中的循环语句 wx:for 来实现。