1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

FlutterとSwiftUiの宣言的UI実装比較 -- スポーツ専用動画配信アプリオマージュ

Last updated at Posted at 2021-04-05

宣言的UIであるFlutterとSwiftUiの実装を比較してみました第二弾です。今回は、某スポーツ専用動画配信アプリを題材としました。難しい箇所が全くないので、初学者でも容易に実装できると思います。

#完成版
##Flutter
flutter1.PNG

##SwiftUi
swiftui.PNG

#UIの土台
まずは、Flutterです。
tab切替用のclassであるTopView classにtabタップで表示するviewを設定します。今回は HomeView classのみ作成します。 HomeView classではCupertinoPageScaffold widgetを使って、headerを作成します。SingleChildScrollView widgetを使って、画面サイズが小さい端末の場合は、スクロールしてコンテンツがちゃんと見れるようにしています。
TopView classHomeView classStatefulWidget classを継承しています。画面操作のみでUIを変更する可能性がある場合は、このほうが良いです。

top_view.dart
class TopView extends StatefulWidget {
  TopView({Key key}) : super(key: key);
  @override
  _TopViewState createState() => _TopViewState();
}

class _TopViewState extends State<TopView> {
  @override
  Widget build(BuildContext context) {
    return CupertinoTabScaffold(
      tabBar: CupertinoTabBar(
        activeColor: Colors.white,
        items: [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'ホーム'),
          BottomNavigationBarItem(
              icon: Icon(Icons.calendar_today), label: '番組表'),
          BottomNavigationBarItem(
              icon: Icon(Icons.sports_soccer), label: 'スポーツ一覧'),
          BottomNavigationBarItem(icon: Icon(Icons.more_horiz), label: 'その他'),
        ],
      ),
      tabBuilder: (context, index) {
        return CupertinoTabView(
          builder: (context) {
            switch (index) {
              case 0:
                return HomeView();
                break;
              case 1:
                return Container();
                break;
              case 2:
                return Container();
                break;
              case 3:
                return Container();
                break;
              default:
                return Container();
            }
          },
        );
      },
    );
  }
}
home_view.dart
class HomeView extends StatefulWidget {
  final ValueChanged<String> onChangedTitle;

  HomeView({this.onChangedTitle});

  @override
  _HomeViewState createState() => _HomeViewState();
}

class _HomeViewState extends State<HomeView> {
  @override
  Widget build(BuildContext context) {
    //widget.onChangedTitle(value.toString());
    return CupertinoPageScaffold(
        backgroundColor: Colors.black.withOpacity(0.7),
        navigationBar: CupertinoNavigationBar(
            backgroundColor: HexColor.fromHex(baseBackgroundColor),
            leading: Icon(CupertinoIcons.search, size: 20.0),
            middle: TextHeader(text: 'ホーム')),
        child: SingleChildScrollView(
            child: Container(
                padding: EdgeInsets.only(
                    top: 10.0, right: 10.0, left: 10.0, bottom: 50.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Stack(children: [BigMovieCell(), BigMovieCellOverlay()]),
                    SizedBox(height: 10),
                    Text16(text: '配信中'),
                    SizedBox(height: 10),
                    Broadcasting(),
                    SizedBox(height: 30),
                    Text16(text: 'XXXリーグ'),
                    SizedBox(height: 10),
                    League(),
                  ],
                ))));
  }
}

次は、SwiftUiです。
tab切替用のstructであるTopScreen structの各tabに、Header表示用structである ScreenBase structを設定します。
TopScreen structではtabBarの背景色を黒くして、アイコンを白色にしています。

TopScreen.swift
struct TopScreen: View {
    init() {
        UITabBar.appearance().backgroundColor = .black
        UITabBar.appearance().backgroundImage = UIImage()
        UITabBar.appearance().barTintColor = .white
    }
    var body: some View {
        TabView{
            ScreenBase(child: HomeScreen())
                 .tabItem {
                     VStack {
                         Image(systemName: "house.fill")
                         Text("ホーム")
                     }
             }.tag(1)
            ScreenBase(child: HomeScreen())
                 .tabItem {
                     VStack {
                         Image(systemName: "square.grid.2x2.fill")
                         Text("番組表")
                     }
             }.tag(2)
            ScreenBase(child: HomeScreen())
                 .tabItem {
                     VStack {
                         Image(systemName: "bell.fill")
                         Text("スポーツ一覧")
                     }
             }.tag(3)
            ScreenBase(child: HomeScreen())
                 .tabItem {
                     VStack {
                         Image(systemName: "ellipsis")
                         Text("その他")
                     }
             }.tag(4)
        }.accentColor(.white)
        
    }
}

ScreenBase structは、headerを表示するためのtemplateです。ここでは検索アイコンと画面タイトルを表示しています。

ScreenBase.swift
struct ScreenBase<T: View>: View {
    
    let child: T
    
    init(child: T) {
        
        self.child = child
        
        let coloredNavAppearance = UINavigationBarAppearance()
            coloredNavAppearance.configureWithOpaqueBackground()
            coloredNavAppearance.backgroundColor = UIColor(hex: headerBackgroundColor)
            coloredNavAppearance.titleTextAttributes = [.foregroundColor: UIColor.white]
            coloredNavAppearance.largeTitleTextAttributes = [.foregroundColor: UIColor.white]
            UINavigationBar.appearance().standardAppearance = coloredNavAppearance
            UINavigationBar.appearance().scrollEdgeAppearance = coloredNavAppearance
    }
    
    var body: some View {
        NavigationView {
            ScrollView {
                child
            }
            .padding(EdgeInsets.init(top: 10.0, leading: 15.0, bottom: 0.0, trailing: 15.0))
            .background(Color.init(hex: baseBackgroundColor))
            .navigationBarTitle("ホーム", displayMode: .inline)
            .navigationBarItems(leading:
                Button(action: {}) {
                    Image(systemName: "magnifyingglass")
                }
            )
        }
    }
}

HomeScreen structはホーム画面の本体です。ここで動画の再生、サムネイルリストを表示します。

HomeScreen.swift
struct HomeScreen: View {
    
    @ObservedObject var homeViewModel = HomeViewModel()
    
    @State var isShowMovie = false
    @State var movieHight = 0.0
    @State var isRecord = false
    @State var isWatching = false
    @State var isWatchingId = 0
    
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            if isShowMovie {
                VStack {
                    ZStack {
                        BigMovieCell(movieHight: movieHight, isRecord: isRecord).frame(height: CGFloat(movieHight) + (isRecord ? CGFloat(recordHeight) : 0.0))
                        BigMovieCellOverlay(movieHight: movieHight + (isRecord ? recordHeight : 0.0))
                    }
                    Spacer()
                }
            }
            Broadcasting(isShowMovie: $isShowMovie, movieHight: $movieHight, isRecord: $isRecord, isWatchingId: $isWatchingId)
            Spacer1()
            League(isShowMovie: $isShowMovie, movieHight: $movieHight, isRecord: $isRecord, isWatchingId: $isWatchingId)
        }
    }
}

#動画再生
Flutter
flutter2.PNG

SwiftUi
swift10.PNG
まずは、Flutterです。
各要素をColumn widgetRow widgetMainAxisAlignmentを駆使して適切な位置に配置しています。ポイントとして、SliderはCupertinoTabScaffold widgetを使用する場合は、CupertinoSlider widgetを使用する必要があります。通常のScaffoldの場合は、Slider widgetで問題ありません。

big_movie_cell_overlay.dart
class BigMovieCellOverlay extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
        width: double.infinity,
        padding: EdgeInsets.all(10.0),
        height: 200.0,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Row(mainAxisAlignment: MainAxisAlignment.end, children: [
              Icon(Icons.fullscreen),
              SizedBox(width: 10),
              Icon(Icons.menu),
              SizedBox(width: 10),
              Icon(Icons.clear),
            ]),
            Row(mainAxisAlignment: MainAxisAlignment.center, children: [
              Icon(Icons.replay_30, size: 50.0, color: Colors.white24),
              SizedBox(width: 10),
              Icon(Icons.pause, size: 50.0, color: Colors.white24),
              SizedBox(width: 10),
              Icon(Icons.forward_30, size: 50.0, color: Colors.white24),
            ]),
            SizedBox(
              width: double.infinity,
              child: CupertinoSlider(
                  min: 0,
                  max: 100,
                  divisions: 10,
                  value: 0,
                  onChanged: (d) => {}),
            ),
            Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
              Text10(text: '00:22', fontWeight: FontWeight.normal),
              Row(
                children: [
                  Icon(Icons.screen_share),
                  SizedBox(width: 10),
                  Icon(Icons.arrow_circle_up),
                  SizedBox(width: 10),
                  Text10(text: '35:22', fontWeight: FontWeight.normal),
                ],
              )
            ]),
          ],
        ));
  }
}

次は、SwiftUiです。
BigMovieCellOverlay structに再生中の動画に対する操作を行うためのcomponentを配置します。VStackHStackを使って簡単に実現できますね。動画再生の進捗を表すインジケータは ZStack内にRectangleを使って、ツマミとバーを実現しています。

BigMovieCellOverlay.swift
struct BigMovieCellOverlay: View {
    
    var movieHight = 0.0
    @State private var currentValue: Double = 50
    
    init(movieHight: Double) {
        self.movieHight = movieHight
    }
    
    var body: some View {
        VStack(spacing: 0.0) {
            HStack(spacing: 10.0) {
                Spacer()
                Image(systemName: "arrow.up.left.and.arrow.down.right").foregroundColor(Color.init(hex: "ffffff"))
                Image(systemName: "line.horizontal.3").foregroundColor(Color.init(hex: "ffffff"))
                Image(systemName: "multiply").foregroundColor(Color.init(hex: "ffffff"))
            }
            SpacerH(height: 10.0)
            HStack {
                Spacer()
                Image(systemName: "gobackward.30")
                    .resizable()
                    .frame(width: 40.0, height: 40.0, alignment: .center)
                    .foregroundColor(Color.init(hex: "444444"))
                SpacerW(width: 20.0)
                Image(systemName: "pause")
                    .resizable()
                    .frame(width: 40.0, height: 40.0, alignment: .center)
                    .foregroundColor(Color.init(hex: "444444"))
                SpacerW(width: 20.0)
                Image(systemName: "goforward.30")
                    .resizable()
                    .frame(width: 40.0, height: 40.0, alignment: .center)
                    .foregroundColor(Color.init(hex: "444444"))
                Spacer()
            }
            SpacerH(height: 40.0)
            ZStack(alignment: .leading) {
                            Rectangle()
                                .foregroundColor(.gray).frame(height: 4.0).cornerRadius(12)
                            Rectangle()
                                .fill(Color.white)
                                .frame(width:12, height: 12)
                                .rotationEffect(Angle(degrees: 45))
                                
                        }
            SpacerH(height: 30.0)
            HStack {
                Text3(text: "00:22").foregroundColor(Color.init(hex: "ffffff"))
                Spacer()
                Image(systemName: "tv").foregroundColor(Color.init(hex: "ffffff"))
                Image(systemName: "person.fill.viewfinder").foregroundColor(Color.init(hex: "ffffff"))
                Text3(text: "35:26").foregroundColor(Color.init(hex: "ffffff"))
            }
        }
        .padding()
        .frame(height: CGFloat(movieHight), alignment: .topLeading)
        .background(Color.clear)
        
    }
}

#配信中リスト
Flutter
flutter3.PNG

SwiftUi
swiftui.PNG

配信中リストは横スクロールのサムネイルを配置するだけです。選択中の動画のサムネイルには黄色枠をつけています。
まずは、Flutterです。
Broadcasting classListView widgetにAxis.horizontalを設定して、サムネイルの横スクルールリストを実現しています。"●ライブ"は、Stack widgetを使って、サムネイルに重ねて表示しています。各サムネイルは後述するBigPicCell classに実装しています。

broadcasting.dart
class Broadcasting extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      height: 200.0,
      child: ListView(
        scrollDirection: Axis.horizontal,
        children: [
          Stack(children: [
            BigPicCell(
                isStreaming: true,
                movie: Movie(
                    id: 1,
                    title: 'AAAA X BBBB',
                    imageName: 'bigpic',
                    leagueName: 'T league',
                    dateTime: '9/12 sun 18:00-')),
            Positioned(top: 15, left: 15, child: LiveLabel())
          ]),
          SizedBox(width: 10),
          Stack(children: [
            BigPicCell(
                movie: Movie(
                    id: 2,
                    title: 'AAAA X BBBB',
                    imageName: 'bigpic',
                    leagueName: 'T league',
                    dateTime: '9/12 sun 18:00-')),
            Positioned(top: 15, left: 15, child: LiveLabel())
          ]),
          SizedBox(width: 10),
          Stack(children: [
            BigPicCell(
                movie: Movie(
                    id: 3,
                    title: 'AAAA X BBBB',
                    imageName: 'bigpic',
                    leagueName: 'T league',
                    dateTime: '9/12 sun 18:00-')),
            Positioned(top: 15, left: 15, child: LiveLabel())
          ]),
          SizedBox(width: 10),
          Stack(children: [
            BigPicCell(
                movie: Movie(
                    id: 4,
                    title: 'AAAA X BBBB',
                    imageName: 'bigpic',
                    leagueName: 'T league',
                    dateTime: '9/12 sun 18:00-')),
            Positioned(top: 15, left: 15, child: LiveLabel())
          ]),
          SizedBox(width: 10),
          Stack(children: [
            BigPicCell(
                movie: Movie(
                    id: 5,
                    title: 'AAAA X BBBB',
                    imageName: 'bigpic',
                    leagueName: 'T league',
                    dateTime: '9/12 sun 18:00-')),
            Positioned(top: 15, left: 15, child: LiveLabel())
          ]),
        ],
      ),
    );
  }
}

次は、SwiftUiです。
ScrollViewを使って、サムネイルを横スクロールできるようにします。各サムネイルは後述する BigPicCell structに実装しています。

Broadcasting.swift
struct Broadcasting: View {
    
    @Binding var isShowMovie: Bool
    @Binding var movieHight: Double
    @Binding var isRecord: Bool
    @Binding var isWatchingId: Int

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text1(text: "配信中")
            Spacer3()
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: 0) {
                    BigPicCell(id: 1, title: "AAAA X BBBB", imageName: "bigpic", leagueName: "T league", dateTime: "9/12 sun 18:00-", isWatchingId: isWatchingId, isRecord: false)
                    .onTapGesture {
                        withAnimation(.easeOut(duration: 0.2)) {
                            isWatchingId = 1
                            isRecord = false
                            isShowMovie = false
                            isShowMovie = true
                            if isShowMovie {
                                movieHight = 200.0
                            } else {
                                movieHight = 0.0
                            }
                        }
                    }
                    Spacer()
                    BigPicCell(id: 2, title: "AAAA X BBBB", imageName: "bigpic", leagueName: "T league", dateTime: "9/12 sun 18:00-", isWatchingId: isWatchingId, isRecord: false)
                    Spacer()
                    BigPicCell(id: 3, title: "AAAA X BBBB", imageName: "bigpic", leagueName: "T league", dateTime: "9/12 sun 18:00-", isWatchingId: isWatchingId, isRecord: false)
                    Spacer()
                    BigPicCell(id: 4, title: "AAAA X BBBB", imageName: "bigpic", leagueName: "T league", dateTime: "9/12 sun 18:00-", isWatchingId: isWatchingId, isRecord: false)
                    Spacer()
                    BigPicCell(id: 5, title: "AAAA X BBBB", imageName: "bigpic", leagueName: "T league", dateTime: "9/12 sun 18:00-", isWatchingId: isWatchingId, isRecord: false)
                
                }
            }
        }.frame(minWidth: 0,
                maxWidth: .infinity,
                minHeight: 0,
                maxHeight: .infinity,
                alignment: .topLeading
        ).background(Color.init(hex: baseBackgroundColor))
    }
}

#リーグリスト
Flutter
flutter4.PNG

SwiftUi
swiftui.PNG

リーグリストは配信中リストと同様に横スクロールのサムネイルを配置するだけですが、選択中の動画のサムネイルには黄色枠をつけています。
まずは、Flutterです。
League classBroadcasting classとほとんど同じ実装です。各サムネイルは後述する BigPicCell classに実装しています。

league.dart
class League extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      height: 200.0,
      child: ListView(
        scrollDirection: Axis.horizontal,
        children: [
          BigPicCell(
              movie: Movie(
                  id: 1,
                  title: 'AAAA X BBBB : 第5節',
                  imageName: 'bigpic',
                  leagueName: 'T league',
                  dateTime: '9/12 sun 18:00-')),
          SizedBox(width: 10),
          BigPicCell(
              movie: Movie(
                  id: 2,
                  title: 'AAAA X BBBB : 第5節',
                  imageName: 'bigpic',
                  leagueName: 'T league',
                  dateTime: '9/12 sun 18:00-')),
          SizedBox(width: 10),
          BigPicCell(
              movie: Movie(
                  id: 3,
                  title: 'AAAA X BBBB : 第5節',
                  imageName: 'bigpic',
                  leagueName: 'T league',
                  dateTime: '9/12 sun 18:00-')),
          SizedBox(width: 10),
          BigPicCell(
              movie: Movie(
                  id: 4,
                  title: 'AAAA X BBBB : 第5節',
                  imageName: 'bigpic',
                  leagueName: 'T league',
                  dateTime: '9/12 sun 18:00-')),
          SizedBox(width: 10),
          BigPicCell(
              movie: Movie(
                  id: 5,
                  title: 'AAAA X BBBB : 第5節',
                  imageName: 'bigpic',
                  leagueName: 'T league',
                  dateTime: '9/12 sun 18:00-')),
        ],
      ),
    );
  }
}

BigPicCell classはサムネイルと下部の追加情報の表示を実装します。
Column widgetにサムネイルと説明用のText widgetを設定しています。
サムネイルはBoxDecoration widgetを使って選択中を表す黄色のボーダーラインを実現しています。

big_pic_cell.dart
class BigPicCell extends StatelessWidget {
  final Movie movie;
  final bool isStreaming;
  BigPicCell({this.movie, this.isStreaming = false});

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 200,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            decoration: BoxDecoration(
                border: Border.all(
                    color: Colors.yellow, width: isStreaming ? 2.0 : 0.0)),
            child: Image.asset('assets/images/bigpic.jpg', width: 230),
          ),
          Text16(text: movie.title),
          Text10(text: '${movie.leagueName} | ${movie.dateTime}'),
        ],
      ),
    );
  }
}

次は、SwiftUiです。
League structBroadcasting structとほとんど同じ実装です。各サムネイルは後述する BigPicCell structに実装しています。

League.swift
struct League: View {
    
    @Binding var isShowMovie: Bool
    @Binding var movieHight: Double
    @Binding var isRecord: Bool
    @Binding var isWatchingId: Int
    
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text1(text: "XXXリーグ")
            Spacer3()
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: 0) {
                    BigPicCell(id: 11, title: "AAAA X BBBB : 第5節", imageName: "bigpic", leagueName: "T league", dateTime: "9/12 sun 18:00-", isWatchingId: isWatchingId, isRecord: true)
                    .onTapGesture {
                        withAnimation(.easeOut(duration: 0.2)) {
                            isWatchingId = 11
                            isRecord = true
                            isShowMovie = false
                            isShowMovie = true
                            if isShowMovie {
                                movieHight = 200.0
                            } else {
                                movieHight = 0.0
                            }
                        }
                    }
                    Spacer()
                    BigPicCell(id: 12, title: "AAAA X BBBB : 第5節", imageName: "bigpic", leagueName: "T league", dateTime: "9/12 sun 18:00-", isWatchingId: isWatchingId, isRecord: true)
                    Spacer()
                    BigPicCell(id: 13, title: "AAAA X BBBB : 第5節", imageName: "bigpic", leagueName: "T league", dateTime: "9/12 sun 18:00-", isWatchingId: isWatchingId, isRecord: true)
                    Spacer()
                    BigPicCell(id: 14, title: "AAAA X BBBB : 第5節", imageName: "bigpic", leagueName: "T league", dateTime: "9/12 sun 18:00-", isWatchingId: isWatchingId, isRecord: true)
                    Spacer()
                    BigPicCell(id: 15, title: "AAAA X BBBB : 第5節", imageName: "bigpic", leagueName: "T league", dateTime: "9/12 sun 18:00-", isWatchingId: isWatchingId, isRecord: true)
                
                }
            }
        }.frame(minWidth: 0,
                maxWidth: .infinity,
                minHeight: 0,
                maxHeight: .infinity,
                alignment: .topLeading
        ).background(Color.init(hex: baseBackgroundColor))
    }
}

BigPicCell structはサムネイルと下部の追加情報の表示を実装します。

ZStackに*border(Color.yellow, width: isWatchingId == id ? 2 : 0)*を設定して、サムネイルがタップされた時は黄色枠を表示します。同時にサムネイルがタップされると、そのサムネイルの動画が再生されるので、サムネイル上に Text("● ライブ") を表示します。これは *Group {isRecord ? nil : LiveLabel()}
.padding(EdgeInsets.init(top: 15.0, leading: 15.0, bottom: 0.0, trailing: 0.0))*で実現しています。

BigPicCell.swift
struct BigPicCell: View {
    
    let id: Int
    let title: String
    let imageName: String
    let leagueName: String
    let dateTime: String
    let isWatchingId: Int
    let isRecord: Bool
    
    init(id: Int, title: String, imageName: String, leagueName: String, dateTime: String, isWatchingId: Int, isRecord: Bool) {
        self.id = id
        self.title = title
        self.imageName = imageName
        self.leagueName = leagueName
        self.dateTime = dateTime
        self.isWatchingId = isWatchingId
        self.isRecord = isRecord
    }
    
    fileprivate func bigPicCell() -> some View {
        return VStack(alignment: .leading, spacing: 0.0) {
            ZStack(alignment: .topLeading) {
                Image(imageName)
                    .resizable()
                    .frame(height: 140)
                Group {isRecord ? nil : LiveLabel()}
                .padding(EdgeInsets.init(top: 15.0, leading: 15.0, bottom: 0.0, trailing: 0.0))
            }.border(Color.yellow, width: isWatchingId == id ? 2 : 0)
            Spacer3()
            Text2(text: title)
            Text3(text: "\(leagueName) | \(dateTime)")
        }
        .frame(width: 250)
        .padding(EdgeInsets.init(top: 5.0, leading: 5.0, bottom: 5.0, trailing: 5.0))
    }
    
    var body: some View {
        VStack(spacing: 0.0) {
            bigPicCell()
        }
    }
}

#まとめ
今回は、私が愛用しているスポーツ専用動画配信アプリを参考に、FlutterとSwiftUiの比較を行いました。headerの作りはflutterの方が簡単です。SwiftUiはNavigationとtabの装飾が非常にわかりにくいです。ページ遷移を含めたtabの書き方は、タップ要素と遷移先をセットで記述できるSwiftUiの方が簡潔にかけます。ただ、分けて書くflutterの方が好きな人もいると思います。このへんは好みですね。
ただ、SwiftUiでVStackHStackのdefault spaceを消すために、spacing: 0.0を毎回設定しないといけないのは手間に感じます。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?