flutter 入门与实战

这篇博客介绍了如何使用Flutter进行移动应用开发,从定义网络框架获取数据开始,逐步构建实体存储和数据转换,接着详细阐述了UI界面的构建过程,包括顶部小部件、电影item布局、圆角背景、五星好评控件的设计,以及如何整合影院热映和即将上映页面,最后展示了如何将获取到的数据渲染到组件上。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在这里插入图片描述

定义网络框架获取网络数据

 import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:douban_flutter/model/movie_news.dart';
import 'package:html/dom.dart' as dom;
//show关键字表示只引用一点
import 'package:html/parser.dart' show parse;
//as指定固定前缀
import 'package:http/http.dart' as http;
class ApiClient {
  static const String baseUrl = 'http://api.douban.com/v2/movie/';
  static const String apiKey = '0b2bdeda43b5688921839c8ecb20399b';
  static const String webUrl = 'https://movie.douban.com/';
  var dio = ApiClient.createDio();
  ///从豆瓣网页上获取需要的轮播图片
  Future<List<MovieNews>> getNewsList() async{
    //获取的轮播图信息列表
    List<MovieNews> news = [];
    await http.get(webUrl).then((http.Response response){
        var document = parse(response.body.toString());
        // 获取所有指定类名的元素gallery-frame获取轮播图数据
        List<dom.Element> items = document.getElementsByClassName('gallery-frame');
        items.forEach((item) {
          String cover = item.getElementsByTagName('img')[0].attributes['src'].toString();//根据网页<img/>便签获取图片
          String link = item.getElementsByTagName('a')[0].attributes['href'].toString();//根据网页<a/>标签获取链接
          String title = item.getElementsByTagName('h3')[0].text.toString().trim();//根据网页<h3/>标签获取标题
          String summary =item.getElementsByTagName('p')[0].text.toString().trim();//根据网页<p/>标签获取段落
          MovieNews movieNews = new MovieNews(title, cover, summary, link);
          news.add(movieNews);
        });
    });
    return news;
  }
  /// 获取影院热映电影
  Future<dynamic> getNowPlayingList({int start, int count}) async {
    Response<Map> response = await dio.get('in_theaters', queryParameters: {"start":start, 'count':count});
    return response.data['subjects'];
  }

  /// 获取即将上映电影
  Future<dynamic>   getComingList({int start, int count}) async {
    Response<Map> response = await dio.get('coming_soon', queryParameters: {"start":start, 'count':count});
    return response.data['subjects'];
  }

  ///配置请求参数
  static Dio createDio() {
    var options = BaseOptions(
      // 请求路径,如果 `path` 以 "http(s)"开始, 则 `baseURL` 会被忽略; 否则,
      //将会和baseUrl拼接出完整的的url.
        baseUrl: baseUrl,
        // 连接服务器超时时间,单位是毫秒.
        connectTimeout: 10000,
        //响应流上前后两次接受到数据的间隔,单位为毫秒。如果两次间隔超过[receiveTimeout],
        receiveTimeout: 10000,
        // 请求的Content-Type,默认值是[ContentType.JSON].
        //如果您想以"application/x-www-form-urlencoded"格式编码请求数据,
        //可以设置此选项为 `ContentType.parse("application/x-www-form-urlencoded")`,  这样[Dio]
        //就会自动编码请求体.
        contentType: ContentType.json,
        //添加固定参数
        queryParameters: {
          "apikey":apiKey
        }
    );
    return Dio(options);
  }
}

构建实体存储电影列表数据

import 'MovieActor.dart';
import 'MovieImage.dart';
import 'MovieRate.dart';

/// 电影简介 item

class MovieItem {
  List  genres;
  MovieRate rating;
  String title;
  String year;
  MovieImage images;
  String id;
  String mainlandPubdate;
  int collectCount;
  List<MovieActor> casts;
  List<MovieActor> directors;


  MovieItem(
      this.genres,
      this.title,
      this.year,
      this.images,
      this.id,
      this.rating,
      this.mainlandPubdate,
      this.collectCount,
      this.casts,
      this.directors,
      );

  MovieItem.fromJson(Map data) {
    id = data['id'];
    images = MovieImage.fromJson(data['images']);
    year = data['year'];
    title = data['title'];
    genres = data['genres']?.cast<String>()?.toList();
    rating =MovieRate.fromJson(data['rating']);
    mainlandPubdate = data['mainland_pubdate'];
    collectCount =data['collect_count'];

    List<MovieActor> castsData = [];
    List<MovieActor> directorsData = [];

    for (var i = 0; i < data['casts'].length; i++) {
      castsData.add(MovieActor.fromJson(data['casts'][i]));
    }
    for (var i = 0; i < data['directors'].length; i++) {
      directorsData.add(MovieActor.fromJson(data['directors'][i]));
    }
    casts = castsData;
    directors = directorsData;
  }
}

使用工具类将数据变成实体类

import 'package:douban_flutter/model/MovieItem.dart';
import 'package:douban_flutter/model/MoviePhoto.dart';


class MovieDataUtil {
  static List<MovieItem> getMovieList(var list) {
    List content = list;
    List<MovieItem> movies = [];
    content.forEach((data) {
      movies.add(MovieItem.fromJson(data));
    });
    return movies;
  }
  static List<MoviePhoto> getPhotoList(var list) {
    List content = list;
    List<MoviePhoto> photos = [];
    content.forEach((data) {
      photos.add(MoviePhoto.fromJson(data));
    });
    return photos;
  }
}

构建界面UI

顶部小部件

在这里插入图片描述

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class HomeSectionView extends StatelessWidget {
  final String title;
  final String action;

  HomeSectionView(this.title, this.action);

  @override
  Widget build(BuildContext context) {

    return Container(
        color: Colors.white,
        padding: EdgeInsets.fromLTRB(15, 15, 15, 5),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: <Widget>[
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  '$title',
                  style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
                ),
                SizedBox(height: 5,),
                Container(
                  width: 80,
                  height: 2,
                  color: Colors.black,
                )
              ],
            ),
            GestureDetector(
                onTap: () {
                  if (action == 'search') {
                    //AppNavigator.push(context, MovieClassifyListView());
                  } else {
                    //AppNavigator.pushMovieList(context, title, action);
                  }
                },
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.center,
                  children: <Widget>[
                    Text('全部',style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),),
                    SizedBox(width: 3,),
                    Icon(CupertinoIcons.forward, size: 14,),
                  ],
                )
            )
          ],
        )
    );
  }
}

显示电影item的布局

在这里插入图片描述

圆角背景图片
import 'package:flutter/material.dart';

class MovieCoverImage extends StatelessWidget {
  final String imgUrl;
  final double width;
  final double height;


  MovieCoverImage(this.imgUrl, {this.width, this.height});

  @override
  Widget build(BuildContext context) {
    return Container(
      child: ClipRRect(
        child: Image(
          //image: CachedNetworkImageProvider(imgUrl),
          image: NetworkImage(imgUrl),
          fit: BoxFit.cover,
          width: width,
          height: height,
        ),
        borderRadius: BorderRadius.circular(3.0),
      ),
      decoration: BoxDecoration(borderRadius: BorderRadius.circular(20.0)),
    );
  }
}
五星好评控件
import 'package:flutter/widgets.dart';
import 'dart:math' as Math;

const double kMaxRate = 5.0;
const int kNumberOfStarts = 5;
const double kSpacing = 3.0;
const double kSize = 50.0;
//五星好评控件
class StaticRatingBar extends StatelessWidget {
  /// number of stars
  final int count;

  /// init rate
  final double rate;

  /// size of the starts
  final double size;

  final Color colorLight;

  final Color colorDark;

  StaticRatingBar({
    double rate,
    Color colorLight,
    Color colorDark,
    int count,
    this.size: kSize,
  })  : rate = rate ?? kMaxRate,
        count = count ?? kNumberOfStarts,
        colorDark = colorDark ?? new Color(0xffd5d5d5),
        colorLight = colorLight ?? new Color(0xffFF962E);

  Widget buildStar() {
    return new SizedBox(
        width: size * count,
        height: size,
        child: new CustomPaint(
          painter: new _PainterStars(
              size: this.size / 2,
              color: colorLight,
              style: PaintingStyle.fill,
              strokeWidth: 0.0),
        ));
  }

  Widget buildHollowStar() {
    return new SizedBox(
        width: size * count,
        height: size,
        child: new CustomPaint(
          painter: new _PainterStars(
              size: this.size / 2,
              color: colorDark,
              style: PaintingStyle.fill,
              strokeWidth: 0.0),
        ));
  }

  @override
  Widget build(BuildContext context) {
    return new Stack(
      children: <Widget>[
        buildHollowStar(),
        new ClipRect(
          clipper: new _RatingBarClipper(width: rate * size),
          child: buildStar(),
        )
      ],
    );
  }
}

class _RatingBarClipper extends CustomClipper<Rect> {
  final double width;

  _RatingBarClipper({this.width}) : assert(width != null);

  @override
  Rect getClip(Size size) {
    return new Rect.fromLTRB(0.0, 0.0, width, size.height);
  }

  @override
  bool shouldReclip(_RatingBarClipper oldClipper) {
    return width != oldClipper.width;
  }
}



class _PainterStars extends CustomPainter {
  final double size;
  final Color color;
  final PaintingStyle style;
  final double strokeWidth;

  _PainterStars({this.size, this.color, this.strokeWidth, this.style});

  /// 角度转弧度公式
  double degree2Radian(int degree) {
    return (Math.pi * degree / 180);
  }

  Path createStarPath(double radius, Path path) {
    double radian = degree2Radian(36); // 36为五角星的角度
    double radiusIn = (radius * Math.sin(radian / 2) / Math.cos(radian)) *
        1.1; // 中间五边形的半径,太正不是很好看,扩大一点点

    path.moveTo((radius * Math.cos(radian / 2)), 0.0); // 此点为多边形的起点
    path.lineTo((radius * Math.cos(radian / 2) + radiusIn * Math.sin(radian)),
        (radius - radius * Math.sin(radian / 2)));
    path.lineTo((radius * Math.cos(radian / 2) * 2),
        (radius - radius * Math.sin(radian / 2)));
    path.lineTo(
        (radius * Math.cos(radian / 2) + radiusIn * Math.cos(radian / 2)),
        (radius + radiusIn * Math.sin(radian / 2)));
    path.lineTo((radius * Math.cos(radian / 2) + radius * Math.sin(radian)),
        (radius + radius * Math.cos(radian)));
    path.lineTo((radius * Math.cos(radian / 2)), (radius + radiusIn));
    path.lineTo((radius * Math.cos(radian / 2) - radius * Math.sin(radian)),
        (radius + radius * Math.cos(radian)));
    path.lineTo(
        (radius * Math.cos(radian / 2) - radiusIn * Math.cos(radian / 2)),
        (radius + radiusIn * Math.sin(radian / 2)));
    path.lineTo(0.0, (radius - radius * Math.sin(radian / 2)));
    path.lineTo((radius * Math.cos(radian / 2) - radiusIn * Math.sin(radian)),
        (radius - radius * Math.sin(radian / 2)));

    path.lineTo((radius * Math.cos(radian / 2)), 0.0);
    return path;
  }

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = new Paint();
    //   paint.color = Colors.redAccent;
    paint.strokeWidth = strokeWidth;
    paint.color = color;
    paint.style = style;

    Path path = new Path();

    double offset = strokeWidth > 0 ? strokeWidth + 2 : 0.0;

    path = createStarPath(this.size - offset, path);
    path = path.shift(new Offset(this.size * 2, 0.0));
    path = createStarPath(this.size - offset, path);
    path = path.shift(new Offset(this.size * 2, 0.0));
    path = createStarPath(this.size - offset, path);
    path = path.shift(new Offset(this.size * 2, 0.0));
    path = createStarPath(this.size - offset, path);
    path = path.shift(new Offset(this.size * 2, 0.0));
    path = createStarPath(this.size - offset, path);

    if (offset > 0) {
      path = path.shift(new Offset(offset, offset));
    }
    path.close();

    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(_PainterStars oldDelegate) {
    return oldDelegate.size != this.size;
  }
}
item整体显示界面

每行三个电影item

import 'package:douban_flutter/model/MovieItem.dart';
import 'package:douban_flutter/util/app_color.dart';
import 'package:douban_flutter/util/screen.dart';
import 'package:douban_flutter/widget/MovieCoverImage.dart';
import 'package:douban_flutter/widget/StaticRatingBar.dart';
import 'package:flutter/material.dart';

//影院热映
class HomeMovieCoverView extends StatelessWidget {
  final MovieItem movie;

  HomeMovieCoverView(this.movie);


  @override
  Widget build(BuildContext context) {
    // 单个电影的宽度
    // 一行放置 3 个 电影
    var width = (Screen.width - 15 * 4) / 3;

    return GestureDetector(
      onTap: () {
        //AppNavigator.pushMovieDetail(context, movie);
      },
      child: Container(
        width: width,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            MovieCoverImage(movie.images.small, width: width, height: width / 0.75,),
            SizedBox(height: 5,),
            Text(
              movie.title,
              overflow: TextOverflow.ellipsis,
              style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
              maxLines: 1,
            ),
            SizedBox(height: 3,),
            Row(
              mainAxisAlignment: MainAxisAlignment.start,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: <Widget>[
                new StaticRatingBar(size: 13.0,rate: movie.rating.average/2,),
                SizedBox(width: 5,),
                Text(movie.rating.average.toString(),style: TextStyle(color: AppColor.grey, fontSize: 12.0),)
              ],
            ),
          ],
        ),
      ),
    );
  }
}

整合影院热映和即将上映页面

使用Wrap组件使内部组件进行换行排列

import 'package:douban_flutter/model/MovieItem.dart';
import 'package:flutter/material.dart';
import 'HomeMovieCoverView.dart';
import 'HomeSectionView.dart';
import 'MovieComingView.dart';


class MovieThreeGridView extends StatelessWidget {
  final List<MovieItem> movies;
  final String title;
  final String action;

  MovieThreeGridView(this.movies, this.title, this.action);

  @override
  Widget build(BuildContext context) {
    var children;
    switch (title) {
      case '影院热映':
        print(movies.length);
        children = movies.map((movie) =>
            HomeMovieCoverView(movie)).toList();
        break;
      case '即将上映':
        children = movies.map((movie) =>
            MovieComingView(movie)).toList();
        break;
      default:
        break;
    }

    return Container(
      color: Colors.white,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[

          HomeSectionView(title,action),
          Container(
            padding: EdgeInsets.fromLTRB(15, 10, 0, 10),
            /**
             * Wrap按宽高自动换行布局
             * spacing主轴方向的间距 水平方向的间距
             * runSpacing run的间距 垂直方向的间距
             */
            child: Wrap(spacing: 15, runSpacing: 20, children: children,),
          ),
          Container(
            height: 10,
            color: Color(0xFFF5F5F5),
          )
        ],
      ),
    );
  }
}

主页将获取到的数据渲染到组件上

import 'package:douban_flutter/model/movie_news.dart';
import 'package:douban_flutter/net/api_client.dart';
import 'package:douban_flutter/util/MovieDataUtil.dart';
import 'package:douban_flutter/util/app_color.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'MovieThreeGridView.dart';
import 'home_news_banner_view.dart';
class HomePage extends StatefulWidget{
  @override
  _HomeState createState() =>_HomeState();

}
/**
 * AutomaticKeepAliveClientMixin需要用到页面保持状态,使他不销毁不重绘
 */
class _HomeState extends State<HomePage> with AutomaticKeepAliveClientMixin{
  var newsList;
  var nowPlayingList, comingList;
  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    fetchData();
  }
  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    if (nowPlayingList == null) {
      return new Center(
        //ios菊花进度条 等数据加载结束后消失
        child: new CupertinoActivityIndicator(
        ),
      );
    } else {
      return Container(
        //下拉刷新组件
          child: RefreshIndicator(
            color: AppColor.red,
            onRefresh: fetchData,
            child: ListView(
              //表示是否将列表项包裹在AutomaticKeepAlive中
              addAutomaticKeepAlives: true,
              // 防止 children 被重绘,设置预加载的区域
              cacheExtent: 10000,
              children: <Widget>[
                new NewsBannerView(newsList),
                new MovieThreeGridView(nowPlayingList, '影院热映', 'in_theaters'),
                new MovieThreeGridView(comingList, '即将上映', 'coming_soon'),
              ],
            ),
          )
      );
    }
  }

  @override
  // TODO: implement wantKeepAlive
  bool get wantKeepAlive => true;
  // 加载数据
  Future<void> fetchData() async {
    ApiClient client = new ApiClient();
    List<MovieNews> news = await client.getNewsList();
    var nowPlayingData = await client.getNowPlayingList(start: 0, count: 6);
    var comingListData = await client.getComingList(start: 0, count: 6);
    setState(() {
      newsList =news2Banner(news);
      comingList = MovieDataUtil.getMovieList(comingListData);
      nowPlayingList = MovieDataUtil.getMovieList(nowPlayingData);
    });
  }

  /**
   * 构建新的banner实体
   */
  List<NewsBanner> news2Banner(var list) {
    List content = list;
    List<NewsBanner> banners = [];
    content.forEach((data) {
      banners.add(new NewsBanner(data));
    });
    return banners;
  }

}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值