Comparison of Flutter and SwiftUi Declarative UI Implementation — A tribute to a famous sports video streaming app.

final version

Flutter on the left and SwiftUi on the right.

The Foundation of the UI

First of all, let’s look at Flutter.

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();
}
},
);
},
);
}
}
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(),
],
))));
}
}
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)

}
}
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")
}
)
}
}
}
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)
}
}
}

Video play

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),
],
)
]),
],
));
}
}
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)

}
}

Distribution List

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())
]),
],
),
);
}
}
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))
}
}

League List

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-')),
],
),
);
}
}
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}'),
],
),
);
}
}
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))
}
}
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()
}
}
}

summary

In this article, I compared Flutter and SwiftUi with reference to my favorite sports-only video streaming application. Flutter is easier for creating headers, while SwiftUi is very confusing for decorating navigation and tab. The way to write tabs including page transitions is more concise in SwiftUi, where you can write the tap element and the transition destination as a set. However, I think there are people who prefer to use Flutter, which is written separately. This is a matter of preference.
However, in SwiftUi, you have to set spacing: 0.0 every time you want to remove the default space of VStack and HStack.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store