In this workshop we'll be building a basic news app that will display different categories of news articles and allow users to view details on each article. Our main interface will be a vertically scrolling list containing horizontal scroll views for different categories (sports, health, business, etc.). Throughout this workshop, we'll primarily explore how to build composable views that we can put together to create more complex views. We'll also explore how to navigate between views.
- Open your Terminal, and navigate to the directory in which you want to download the repository
- Run the following command in Terminal to clone the repo:
git clone https://github.com/C1-SoftwareEngineeringSummit/NewsfeedUI.git
- Navigate to the starter project within the newly cloned repo:
cd NewsfeedUI/NewsfeedUI-Starter
- NOTE: You can also use the Finder app to navigate to
NewsfeedUI-Starter
- NOTE: You can also use the Finder app to navigate to
- Open the starter project in Xcode by running the following command:
xed .
- NOTE: You can also double-click the
NewsfeedUI-Starter.xcodeproj
file in using the Finder app
- NOTE: You can also double-click the
- Once the project is open in Xcode, click on the top level folder (NewsfeedUI-Starter) in the project navigator, then click on "General" settings, and then look for the "Identity" settings section
- Inside Identity settings, modify the "Bundle Identifier" by adding your name to the end of it. For example, change it from
com.ses.NewsfeedUI-Starter.MyName
tocom.ses.NotesUI-Starter-SteveJobs
- NOTE: The goal is to have a unique bundle identifier. Your app won't compile if the bundle identifier is not unique.
- Build and run the project to make sure everything is working fine. Press the symbol near the top left corner of Xcode that looks like a Play
▶️ button or use the shortcut:⌘ + R
If you want this app to work with real API calls, make sure to get a News API development key. This will allow the app to fetch real data. But if you don't have a key, don't worry - this workshop is also set up so that you can use mock data without an API key.
If you do get a key, feel free to open up the Constants.swift
file under the Resources
group, and replace the static let APIKey
empty string with your personal key. This will set the useMockResponses
variable to false
, triggering networking code to hit live data. However, it might be best to do this after going through the workshop because some of the Xcode previews will either be missing or show placeholder values when using live data.
In this workshop, there are a few pre-made files that we'll be using to make things a little easier.
The first file is Constants.swift
. If you have a personal API key to use, you've probably already looked at this file. It's not very complicated, just a place to keep a few constants that are used throughout the rest of the workshop.
Next is APIResponse.swift
. In this file, you'll find the classes NewsArticle
and NewsApiResponse
. These are the data models for our app. NewsArticle
represents a single article, and NewsApiResponse
represents a response from the News API, which contains an array of [NewsArticle]
's.
Models.swift
contains a class called NewsFeed
. This class will fetch and store all of the different news articles from the API. It has a property for each category of news (general
, sports
, health
, entertainment
, business
, science
, & technology
). These properties are arrays of NewsArticle
's corresponding to the different categories. As you can see, NewsFeed
implements the ObservableObject
protocol. An ObservableObject
will publish announcements when it's values have changed so that SwiftUI can react to those changes and update the user interface. The properties in this class (general
, sports
, health
, entertainment
, business
, science
, & technology
) are all marked as @Published
, which tells SwiftUI that these properties should trigger change notifications. Later on in the workshop, you'll see how these properties are used to reactively display news articles. If you want to read more about this topic, this is a good place to start. NewsFeed
also contains a static var sampleData
, which is just an array of sample news articles that we will use to test our app throughout the workshop.
Under the "Views" group, we have also have RemoteImage.swift
. This class defines a View
called RemoteImage
that will download and display an image from any URL that you provide to it. The implementation details are a bit out of the scope of this tutorial, but we will at least see how to use this View
later on in the workshop.
Lastly, we have WebView.swift
. This struct is a SwiftUI wrapper around a SFSafariViewController
which allows us to present a Safari browser view in SwiftUI. Again, this is a bit out of the scope of this tutorial, but feel free to check it out.
The first thing that we'll do in this workshop is create a view that can display multiple featured articles in a "carousel" (or page view). Users will be able to swipe left and right to view different featured articles, and a page control at the bottom will display the current page in the form of highlighted dots.
First, we'll build a CarouselView
, which will display multiple pages of content and allow users to swipe left and right between different pages.
- Create a new SwiftUI file in the
Views
folder calledCarouselView.swift
- To create a new file in the
Views
folder, right-click on the folder and selectNew File...
- Filter the file types for
SwiftUI View
and select that file type - Name the file
CarouselView.swift
, make sure theNewsfeedUI
target is selected in theTargets
list, and clickCreate
- Insert a
var
at the top of theCarouselView
struct calledarticles
that is of type[NewsArticle]
struct CarouselView: View {
var articles: [NewsArticle]
articles
will store an array ofNewsArticle
's that should be displayed in theCarouselView
.
- Update the
PreviewProvider
to initialize the preview with a list of sampleNewsArticle
's
struct CarouselView_Previews: PreviewProvider {
static var previews: some View {
CarouselView(articles: Array(NewsFeed.sampleData.prefix(3)))
}
}
- This will retrieve the first 3
NewsArticle
's in oursampleData
and pass them to theCarouselView
in the preview.
- Replace the
Text
in thebody
with aTabView
that contains thetitle
of each article in thearticles
array:
var body: some View {
TabView() {
ForEach(articles) { article in
Text(article.title)
}
}
.aspectRatio(3 / 2, contentMode: .fit)
.tabViewStyle(PageTabViewStyle())
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
}
- The
ForEach
loop goes through thearticles
array and creates aText
view for eacharticle
in the array. For now, thearticle
'stitle
is the only thing displayed in theCarouselView
. - A
TabView
is aView
that can switch between different child views. In our case, the child views are the different article titles displayed byText(article.title)
. TheTabView
is the most important piece of ourCarouselView
since it's what allows us to swipe between different featuredNewsArticle
's. .aspectRatio(3 / 2, contentMode: .fit)
sets the aspect ratio of ourTabView
to 3:2 (width:height). By setting.contentMode
to.fit
we tell theTabView
to scale up or down so that it fits perfectly within a 3:2 frame..tabViewStyle(PageTabViewStyle())
sets ourTabView
's style toPageTabViewStyle
. This is what makes ourTabView
appear as a page view (or "carousel") with highlighted dots to track our tab position. Without this style, ourTabView
would appear with a full tab bar at the bottom of the screen, much like the iOS Music or Phone app..indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
is what causes the highlighted dots to appear at the bottom of ourTabView
. We are setting the page index (the highlighted dots) to be displayed with a background.always
. We will actually remove this line later, but for now it allows us to view the highlighted dots on a white background.
At this point, you can resume your Canvas preview to see how the
CarouselView
looks so far. If the Canvas is not visible, you can open it by using the small menu to the right of your file tabs. If your Canvas preview is paused, there will be a "Resume" button at the top of the Canvas that you can use. Alternatively, you can use the shortcutCMD + OPT + P
to refresh the Canvas preview.
You can also press the circular
▶️ button directly above the simulator in your Canvas. This will start a live preview of theCarouselView
. You can use your cursor to swipe the pages left and right, and the highlighted dots at the bottom should update as well. Click the ⏹️ button to stop the live demo.
Our CarouselView
works okay for now, but it lacks visual appeal. To fix this, we're going to create a new FeatureView
that will replace the CarouselView
's existing Text(article.title)
. FeatureView
will display an article's image with the title overlayed on top of it.
- Create a new SwiftUI file in the
Views
folder calledFeatureView.swift
- To create a new file in the
Views
folder, right-click on the folder and selectNew File...
- Filter the file types for
SwiftUI View
and select that file type - Name the file
FeatureView.swift
, make sure theNewsfeedUI
target is selected in theTargets
list, and clickCreate
- Insert a
var
at the top of theFeatureView
struct
calledarticle
that is of typeNewsArticle
:
struct FeatureView: View {
var article: NewsArticle
- This will contain the
NewsArticle
that thisFeatureView
will display.
- Update the
PreviewProvider
to initialize the preview with a sampleNewsArticle
struct FeatureView_Previews: PreviewProvider {
static var previews: some View {
FeatureView(article: NewsFeed.sampleData[0])
}
}
- Replace the
Text
View in thebody
with aRemoteImage
View for the currentarticle
:
var body: some View {
RemoteImage(url: article.urlToImage)
.aspectRatio(3 / 2, contentMode: .fit)
}
- We use a
RemoteImage
to download and display the image for thisarticle
. If you haven't added your API key toConstants.swift
,RemoteImage
won't make any network requests. Instead, it displays a random image from ourAssets.xcassets
catalog. If you do however add your API key,RemoteImage
will try to make a network request to pull the live image from the url. Be aware that doing so will cause a default placeholder image to show up in the Canvas preview! .aspectRatio(3 / 2, contentMode: .fit)
sets the aspect ratio for theRemoteImage
and resizes it to fit into a 3:2 frame.
- In the same file, add a new
TextOverlay
View
that will overlay theNewsArticle
'stitle
on top of theRemoteImage
. This newView
should go after theFeatureView
struct
, but beforeFeatureView_Previews
.
struct TextOverlay: View {
var text: String
var gradient: LinearGradient {
LinearGradient(
gradient: Gradient(
colors: [Color.black.opacity(0.8), Color.black.opacity(0)]
),
startPoint: .bottom,
endPoint: .center
)
}
var body: some View {
ZStack(alignment: .bottomLeading) {
Rectangle()
.fill(gradient)
Text(text)
.font(.headline)
.padding()
.padding(.bottom, 25)
}
.foregroundColor(.white)
}
}
var text
will contain the text that is displayed by this overlay. In our app, this will contain the article'stitle
.gradient
is aLinearGradient
. This is a blend between two colors over a given distance. Here, we are transitioning from almost-opaque black at the bottom of thegradient
, to clear in the center of thegradient
. This gradient will darken the bottom half of ourRemoteImage
, allowing the whitetext
to stand out on top of anyRemoteImage
.- The
body
of theTextOverlay
is aZStack
that places thetext
on top of thegradient
. Both of these views are aligned using their.bottomLeading
(bottom-left) corners. - The
Text
has some standard.padding()
applied to all edges, as well as 25 points of additional.padding
on the.bottom
edge. This creates enough space at the bottom so ourtext
and the highlighted dots of ourCarouselView
don't overlap. .foregroundColor(.white)
sets the color of ourtext
to.white
.
- Add a
TextOverlay
to theFeatureView
'sRemoteImage
var body: some View {
RemoteImage(url: article.urlToImage)
.aspectRatio(3 / 2, contentMode: .fit)
.overlay(TextOverlay(text: article.title))
}
Resume the Canvas preview if you haven't already, and see what the
FeatureView
looks like now! You can use the shortcutCMD + OPT + P
to refresh the Canvas preview.
Before we move on, your entire FeatureView.swift
should look like this:
import SwiftUI
struct FeatureView: View {
var article: NewsArticle
var body: some View {
RemoteImage(url: article.urlToImage)
.aspectRatio(3 / 2, contentMode: .fit)
.overlay(TextOverlay(text: article.title))
}
}
struct TextOverlay: View {
var text: String
var gradient: LinearGradient {
LinearGradient(
gradient: Gradient(
colors: [Color.black.opacity(0.8), Color.black.opacity(0)]
),
startPoint: .bottom,
endPoint: .center
)
}
var body: some View {
ZStack(alignment: .bottomLeading) {
Rectangle()
.fill(gradient)
Text(text)
.font(.headline)
.padding()
.padding(.bottom, 25)
}
.foregroundColor(.white)
}
}
struct FeatureView_Previews: PreviewProvider {
static var previews: some View {
FeatureView(article: NewsFeed.sampleData[0])
}
}
Now that we've created a nice FeatureView
, we can use it within our CarouselView
!
- Open
CarouselView.swift
once more and replace theText
inside of theForEach
loop with aFeatureView
. You can also remove the.indexViewStyle
modifier.CarouselView.swift
should now look like this:
import SwiftUI
struct CarouselView: View {
var articles: [NewsArticle]
var body: some View {
TabView() {
ForEach(articles) { article in
FeatureView(article: article)
}
}
.aspectRatio(3 / 2, contentMode: .fit)
.tabViewStyle(PageTabViewStyle())
}
}
struct CarouselView_Previews: PreviewProvider {
static var previews: some View {
CarouselView(articles: Array(NewsFeed.sampleData.prefix(3)))
}
}
Resume the Canvas preview now to see the completed
CarouselView
! You can also start a live preview in the Canvas to make sure that you are still able to swipe left and right between differentFeatureView
's in the carousel.
In this section we'll create the view that displays categories of articles. This new view will display multiple article "cards" in a horizontal scroll view. Each of these horizontal scroll views will contain all articles from a given category (e.g. technology, business, sports, music, etc).
First, we will build a horizontal scroll view to contain all of the articles in a single category. We'll call it CategoryRow
.
-
Create a new SwiftUI file in the
Views
folder calledCategoryRow.swift
-
Insert two
var
s at the top of theCategoryRow
struct calledcategoryName
andarticles
:
struct CategoryRow: View {
var categoryName: String
var articles: [NewsArticle]
categoryName
is the name of this category ("Technology", "Business", "Sports", "Music", etc.)articles
is anArray
ofNewsArticle
s that belong to this category
- Update the
PreviewProvider
to initialize these twovar
s in the preview:
struct CategoryRow_Previews: PreviewProvider {
static var articles = NewsFeed.sampleData
static var previews: some View {
CategoryRow(categoryName: "Sports", articles: articles)
}
}
- Update the
Text
in thebody
to display thecategoryName
rather than the static"Hello, World!"
text:
var body: some View {
Text(categoryName)
.font(.title)
}
- We use
.font(.title)
to style thisText
as a.title
.
- Add an
HStack
(horizontal stack) View below theText
View that will contain all of the articles in the given category:
var body: some View {
Text(categoryName)
.font(.title)
HStack(alignment: .top, spacing: 0) {
ForEach(articles) { article in
Text(article.title.prefix(10))
}
}
}
- When we create the
HStack
, we define thealignment
to be.top
, meaning that all of the items in theHStack
will have their top edges aligned. - The
ForEach
loop goes through thearticles
array and creates aText
for eacharticle
in the array. For now, theText
only displays the first 10 characters in thearticle
'stitle
.
- Group the category name Text View and the horizontal stack View together inside a
VStack
(vertical stack View):
var body: some View {
VStack(alignment: .leading) {
Text(categoryName)
.font(.title)
HStack(alignment: .top, spacing: 0) {
ForEach(articles) { article in
Text(article.title.prefix(10))
}
}
}
}
- We use
.leading
alignment to align all of the vertically stacked content to the leading (left) edge. - This
VStack
will place the category name directly above the horizontal stack of articles. - If you try to resume the Canvas preview at this point, it will look pretty terrible! This is because our
HStack
doesn't scroll yet and therefore squishes all of its content onto the screen at once.
- Add some padding to the category name, and wrap the
HStack
in aScrollView
:
var body: some View {
VStack(alignment: .leading) {
Text(categoryName)
.font(.title)
.padding(.leading, 15)
.padding(.top, 5)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(articles) { article in
Text(article.title.prefix(10))
}
}
}
}
}
- The padding will give our
categoryName
some breathing room on the.top
and.leading
(left) edges. - Wrapping the
HStack
in aScrollView
allows the content in theHStack
to stretch out and take up as much space as it needs! Now the article titles aren't squished together. - Our
ScrollView
specifies.horizontal
, meaning it only scrolls horizontally. showsIndicators: false
means that theScrollView
won't show scroll indicators (like you would typically see on the right side of a web page).
At this point, press the circular
▶️ button to start a live preview of theCategoryRow
in your Canvas. You can use your cursor to swipe theScrollView
and see it in action! Again, click the ⏹️ button to stop the live demo.
Next, we'll build a CategoryItem
View that will display a single news article as a thumbnail and a title. Multiple CategoryItem
s will go inside our CategoryRow
's horizontal ScrollView
to display an entire category of news articles. The image below shows how our CategoryItem
will look.
-
Create a new SwiftUI file in the
Views
folder calledCategoryItem.swift
-
Add a
var
calledarticle
that will contain the specificNewsArticle
to display on this card:
struct CategoryItem: View {
var article: NewsArticle
- When we initialize a new
CategoryItem
we will provide the specificNewsArticle
to display in the card.
- Update the
PreviewProvider
to initialize theCategoryItem
with a sampleNewsArticle
:
struct CategoryItem_Previews: PreviewProvider {
static var previews: some View {
CategoryItem(article: NewsFeed.sampleData[0])
}
}
- Replace the
body
of your view with the following code:
var body: some View {
VStack(alignment: .leading) {
RemoteImage(url: article.urlToImage)
.scaledToFill()
.frame(width: 155, height: 155)
.clipped()
.cornerRadius(5)
Text(article.title)
.lineLimit(5)
.font(.headline)
}
.frame(width: 155)
.padding(.leading, 15)
}
- This
VStack
is just like the one we used inCategoryRow
. It contains aRemoteImage
View and aText
View below it. TheRemoteImage
displays the thumbnail for thearticle
, and theText
displays thearticle
'stitle
.
NOTE: If you never added your API key to
Constants.swift
,RemoteImage
will pull from our locally saved images. Otherwise,RemoteImage
will try to pull the live image. But again, be aware that doing so will cause a default placeholder image to show up in the Canvas preview!
.frame(width: 155, height: 155)
will give our image a square frame of 155 points..scaledToFill()
means that the image will scale to fill the entire 155pt x 155pt frame, and.clipped()
means that any parts of the image outside of the frame will not be visible..cornerRadius(5)
will round the corners of our image with a 5pt radius.- On our
Text
,.lineLimit(5)
prevents thetitle
from extending beyond 5 lines. Any text beyond the 5 line limit will be truncated with a trailing...
. - We also specify
.frame(width: 155)
for the entireVStack
. This restricts the entireVStack
to a width of 155pt, so that none of thearticle
'stitle
will extend beyond the edge of the image. Instead, it will wrap around. You can see this in the Canvas if you resume the preview. - We also added
.padding
to the.leading
(left) edge of theVStack
. When theCategoryItem
s are lined up horizontally, this padding will provide 15 points of space between each item, and it will also provide 15 points of space between the first item and the left edge of our phone screen.
Make sure to open the Canvas and resume the preview to visualize an individual
CategoryItem
!
Before we move on, your entire CategoryItem.swift
should look like this:
import SwiftUI
struct CategoryItem: View {
var article: NewsArticle
var body: some View {
VStack(alignment: .leading) {
RemoteImage(url: article.urlToImage)
.scaledToFill()
.frame(width: 155, height: 155)
.clipped()
.cornerRadius(5)
Text(article.title)
.lineLimit(5)
.font(.headline)
}
.frame(width: 155)
.padding(.leading, 15)
}
}
struct CategoryItem_Previews: PreviewProvider {
static var previews: some View {
CategoryItem(article: NewsFeed.sampleData[0])
}
}
At this point, we have everything we need to create a complete CategoryRow
, so lets integrate the CategoryItem
into the horizontal ScrollView
.
- Open
CategoryRow.swift
, and replaceText(article.title.prefix(10))
with aCategoryItem
that displays the currentarticle
. YourCategoryRow.swift
should now look like this:
import SwiftUI
struct CategoryRow: View {
var categoryName: String
var articles: [NewsArticle]
var body: some View {
VStack(alignment: .leading) {
Text(categoryName)
.font(.title)
.padding(.leading, 15)
.padding(.top, 5)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(articles) { article in
CategoryItem(article: article)
}
}
}
}
}
}
struct CategoryRow_Previews: PreviewProvider {
static var articles = NewsFeed.sampleData
static var previews: some View {
CategoryRow(categoryName: "Sports", articles: articles)
}
}
If you restart live preview in the Canvas, you should be able to see your completed
CategoryRow
! Each article should have a "card" displaying a thumbnail and a title, and the whole category of articles should scroll horizontally.
Now that we've created a CarouselView
and CategoryRow
, we can use those two pieces to create the home screen of our app! The home screen will contain a CarouselView
at the top to highlight a few trending stories, and multiple CategoryRows
to group articles with related content.
-
Open
ContentView.swift
. ThisView
is the first screen that a user sees when they open the app for the first time. -
Add the following property to the
ContentView
struct:
struct ContentView: View {
@ObservedObject var newsFeed = NewsFeed()
@ObservedObject
is a property wrapper. It tells ourContentView
to observe the state of thenewsFeed
and react to any changes. This means that when thenewsFeed
changes, any views that depend on it will be reloaded. This happens when our app finishes fetching news articles and loads them into thenewsFeed
.NewsFeed
is our API request engine. When we create this object, it makes a few different API requests to retrieve different categories of news articles. After these API calls complete, we can access the General category of articles by usingnewsFeed.general
, for example. Again, if you set up your API key at the beginning of this tutorial, you will be fetching live data, but be aware that this will cause the Canvas preview to show up blank.
- In the
body
, wrap the existingText
inside of aNavigationView
and give it a.navigationTitle
:
var body: some View {
NavigationView {
Text("Hello, World!")
.navigationTitle("Newsfeed")
}
}
NavigationView
is used to build hierarchical navigation. It will add a navigation bar to our screen, which will contain the title set by.navigationTitle("Newsfeed")
. Later on, thisNavigationView
will allow us to navigate to new screens when we tap on different articles.
- Next, replace the
Text
with aList
that contains just theCarouselView
for now:
var body: some View {
NavigationView {
List {
if !newsFeed.general.isEmpty {
CarouselView(articles: Array(newsFeed.general.prefix(5)))
.listRowInsets(EdgeInsets())
}
}
.listStyle(PlainListStyle())
.navigationTitle("Newsfeed")
}
}
- A
List
is just a container that will present rows of data arranged in a single column. Right now, we are only providing one row of data, which is ourCarouselView
. - Before we add the
CarouselView
to theList
, we check to see if!newsFeed.general.isEmpty
. We do this because we need at least 1 article innewsFeed.general
in order to create aCarouselView
. Otherwise, we would have nothing to display. If there's at least 1 article innewsFeed.general
, we display theCarouselView
. Otherwise, we don't add it to theList
. - When we create the
CarouselView
, we provide it withArray(newsFeed.general.prefix(5))
. This is will take up to 5 articles from the General category, and display them in theCarouselView
. - We use
.listRowInsets(EdgeInsets())
to set the edge insets to zero for theCarouselView
. Adding thePlainListStyle()
style modifier removes the insets from the entire list. This combined with the row insets allows the content to extend to the edges of the screen.
- Add 3
CategoryRow
's to theList
, one for each of the categories Sports, Health, and Entertainment (you can choose different categories later if you wish):
var body: some View {
NavigationView {
List {
if !newsFeed.general.isEmpty {
CarouselView(articles: Array(newsFeed.general.prefix(5)))
.listRowInsets(EdgeInsets())
}
if !newsFeed.sports.isEmpty {
CategoryRow(categoryName: "Sports", articles: newsFeed.sports)
.listRowInsets(EdgeInsets())
}
if !newsFeed.health.isEmpty {
CategoryRow(categoryName: "Health", articles: newsFeed.health)
.listRowInsets(EdgeInsets())
}
if !newsFeed.entertainment.isEmpty {
CategoryRow(categoryName: "Entertainment", articles: newsFeed.entertainment)
.listRowInsets(EdgeInsets())
}
}
.navigationTitle("Newsfeed")
}
}
- For each of these categories, we use an
if
statement to make sure the category has at least 1 article. If there is at least 1 article, we add aCategoryRow
to theList
. Otherwise, we don't add anything to theList
for the empty category. - The
CategoryRow
's are given acategoryName
and a list ofarticles
to display. For instance, theCategoryRow
for Sports is given the name"Sports"
and thenewsFeed.sports
articles. This uses theCategoryRow
that we built earlier to display all of the Sports articles in a horizontalScrollView
. This is the same for the other categories as well. - Each
CategoryRow
uses.listRowInsets(EdgeInsets())
, which sets the edge insets to zero. Again, this allows the content to extend to the very edges of the screen.
Refresh the Canvas and start a live preview. You should be able to see your completed home screen! You should be able to scroll horizontally between different articles in the
CarouselView
, and you should be able to scroll vertically to view all of the different categories of articles. Additionally, eachCategoryRow
should scroll horizontally.
Now that we have our articles displayed in the home screen, we want to be able to click on those articles to view more details. This detail view will provide important summary information about the article including the title, image, author, date published, summary text, and a button that links to the entire article.
-
Create a new SwiftUI file in the
Views
folder calledDetailView.swift
-
Add a
var
calledarticle
that will contain the specificNewsArticle
to display on this page:
struct DetailView: View {
var article: NewsArticle
- When we initialize a new
DetailView
we will provide the specificNewsArticle
to display in the page.
- Update the
PreviewProvider
to initialize theDetailView
with a sampleNewsArticle
:
struct DetailView_Previews: PreviewProvider {
static var previews: some View {
DetailView(article: NewsFeed.sampleData[4]) // using 4 to get good representative data
- Replace
body
with the following code:
var body: some View {
VStack(alignment: .leading) {
Text(article.title)
.italic()
.font(.title)
.fontWeight(.semibold)
RemoteImage(url: article.urlToImage)
.aspectRatio(contentMode: .fit)
.padding(.bottom)
Text("By: \(article.author ?? "Author")")
.bold()
Text(article.datePublished)
.font(.subheadline)
.padding(.bottom)
Text(article.description ?? "Description")
.font(.body)
.padding(.bottom)
Button("View Full Article") { }
.font(.title3)
.padding(.vertical, 10)
.padding(.horizontal, 50)
.foregroundColor(.white)
.background(Color.blue)
.cornerRadius(10)
}.padding()
}
- A
VStack
withleading
alignment andpadding
is added to wrap the following views: - The
Text
View is updated to use thearticle.title
and modifiers are added foritalic
type,title
font, andsemibold
font weight. RemoteImage
is added underneath the title withbottom
padding and anaspectRatio
set tofit
to fill or shrink the image to fit the screen's width.- We add another
Text
View for the article's author withbold
weight below the image. Notice how we're using string interpolation"\(...)"
here to insert code into the string literal. This allows us to use the nil coalescing operator??
to provide a default value if the optional variablearticle.author
is nil. - Next, the published date is added in a
Text
View withsubheadline
font andbottom
padding. - Followed by another
Text
View withbody
font andbottom
padding. Notice how we need to use the nil coalescing operator again to provide a default value ifarticle.description
is nil. - The last view added is a
Button
using the trailing closure version ofButton(title: StringProtocol, action: () -> Void)
, but we're omitting the action code (what's executed when the button is tapped) in the closure for now. We're also adding a handful of style modifiers includingtitle3
font, 10 points ofvertical
and 50 points ofhorizontal
padding to stretch the button lengthwise,white
foregroundColor
for the button text,blue
background
color, and acornerRadius
of 10 adds curved corners to the button.
- Adding too much style in your main view can get messy, so let's refactor this style into a separate struct that conforms to the
ButtonStyle
protocol. Use the code below to add the newFilledButtonStyle
struct directly after yourDetailView
struct, then modify the original button with.buttonStyle(FilledButtonStyle())
.
Button("View Full Article") { }
.buttonStyle(FilledButtonStyle())
}.padding()
}
}
struct FilledButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.padding(.vertical, 10)
.padding(.horizontal, 50)
.font(.title3)
.foregroundColor(.white)
.background(configuration.isPressed ? Color(red: 0.0, green: 0.3, blue: 0.8) : .blue)
.cornerRadius(10)
}
}
- To conform to
ButtonStyle
we need to implement themakeBody
function which takes aButtonStyleConfiguration
parameter. This simply allows us to access properties relevant to the button such aslabel
andisPressed
. - We add all of our styling we had before to the
configuration.label
property. - Note: we take advantage of the
configuration.isPressed
property to change the background color to a custom darker shade of blueColor(red: 0.0, green: 0.3, blue: 0.8) : .blue)
, which gives feedback to the user that theButton
is in itspressed
state.
- You'll notice that the
Button
is left-aligned due to the alignment of its parentVStack
. We can remedy this by embedding theButton
inside anHStack
and addingSpacer
views before and after theButton
, centering it horizontally. That looks better!
HStack {
Spacer()
Button("View Full Article") { }
.buttonStyle(FilledButtonStyle())
Spacer()
}
- It's possible for the content of
DetailView
to extend past the bottom of the screen. Let's wrap up the UI by embedding ourVStack
inside aScrollView
with a.navigationBarTitleDisplayMode(.inline)
modifier (this makes the nav bar compact when we enter theDetailView
from another screen). Your code should now look something like this:
struct DetailView: View {
var article: NewsArticle
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text(article.title)
.italic()
.font(.title)
.fontWeight(.semibold)
RemoteImage(url: article.urlToImage)
.aspectRatio(contentMode: .fit)
.padding(.bottom)
Text("By: \(article.author ?? "Author")")
.bold()
Text(article.datePublished)
.font(.subheadline)
.padding(.bottom)
Text(article.description ?? "Description")
.font(.body)
.padding(.bottom)
HStack {
Spacer()
Button("View Full Article") { }
.buttonStyle(FilledButtonStyle())
Spacer()
}
}.padding()
}.navigationBarTitleDisplayMode(.inline)
}
}
struct FilledButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.padding(.vertical, 10)
.padding(.horizontal, 50)
.font(.title3)
.foregroundColor(.white)
.background(configuration.isPressed ? Color(red: 0.0, green: 0.3, blue: 0.8) : .blue)
.cornerRadius(10)
}
}
struct DetailView_Previews: PreviewProvider {
static var previews: some View {
DetailView(article: NewsFeed.sampleData[4])
}
}
Now that we have the DetailView
UI finished, we need to hook it up to the rest of the app!
We need to be able to navigate to the DetailView
from two places: FeatureView
s in the main carousel, and CategoryItem
s in the category rows.
- Open up
CarouselView.swift
and embed theFeatureView
inside aNavigationLink
withDetailView(article: article)
as the destination. Add the.buttonStyle(PlainButtonStyle())
style modifier to theNavigationLink
to remove the default blue text color.
ForEach(articles) { article in
NavigationLink(destination: DetailView(article: article)) {
FeatureView(article: article)
}.buttonStyle(PlainButtonStyle())
}
NavigationLink
is a SwiftUI view that controls navigation presentation behind the scenes. This easily enables us to simply wrap our view we want to click on (the navigation label) and provide the destination view we want to open. Here we use the trailing closure version ofNavigationLink(destination: Destination, label: () -> Label)
orNavigationLink(destination: Destination) { some view }
.
- Open up
CategoryRow.swift
and embed theCategoryItem
inside aNavigationLink
withDetailView(article: article)
as the destination. Again, add the.buttonStyle(PlainButtonStyle())
style modifier.
ForEach(articles) { article in
NavigationLink(destination: DetailView(article: article)) {
CategoryItem(article: article)
}.buttonStyle(PlainButtonStyle())
}
Switch to
ContentView.swift
and refresh the Canvas to start a live preview. You should now be able to click on elements in the home screen and navigate to the article details, and back again. That's it for the main navigation of the app!
Now that the main UI is complete, there are a few things you can to do customize the app to make it your own.
-
For starters, you may want to change the default news categories that are displayed in the main view. We included support for all of the categories in News API's top-headlines endpoint:
general
,business
,entertainment
,health
,science
,sports
, andtechnology
. Feel free to display whichever categories you're interested in (or all of them!) by following the process of adding theCategoryRow
s to theContentView
's list from 4. Building the Home Screen. You can also change the name of the navigation title from "Newsfeed" to whatever you want! -
Be creative. SwiftUI makes it very easy to modify style across your views. Check out the SwiftUICheatSheet we provided to customize the look and feel of your app. Some ideas are to change the text font, color scheme, corner radii, etc.
-
If you haven't already, now is a good time to hit live data! Check out Getting Set Up for API Calls from section 1 again for a refresher on setting up and adding your News API key.
Now that we can navigate to the DetailView
from the home screen, let's wrap up by adding functionality to our "View Full Article" button in the DetailView
.
- Open up
DetailView.swift
and add a newvar
underarticle
calledshowWebView
with the@State
property wrapper and assign it tofalse
.
struct DetailView: View {
var article: NewsArticle
@State var showWebView = false
@State
is a property wrapper that signifies the source of truth for theshowWebView
value in ourDetailView
. You can think of this as a reference type (rather than a value type) that can be mutated by other views when passed to them as a binding$
(which we'll see in a bit).
- Replace the
HStack
containing the button code with the following:
HStack {
Spacer()
Button("View Full Article") {
showWebView = true
}
.buttonStyle(FilledButtonStyle())
.sheet(isPresented: $showWebView, content: {
// modally present web view
WebView(url:URL(string: article.url)!)
})
Spacer()
}
showWebView
is set totrue
inside theButton
action closure. This allows the web view to be presented only after theButton
is pressed.sheet(isPresented:onDismiss:content:)
modally presents the givencontent
view whenisPresented
is true.- We pass in
$showWebView
for theisPresented
parameter.$showWebView
is a binding, or a shared property to ourshowWebView
state variable. This is set totrue
by theDetailView
when the button is pressed, and set tofalse
again by thesheet
on dismissal. Binding to a state property allows us to modify the property by different views while keeping one source or truth for the value. - The
content
is our pre-definedWebView
which is just a SwiftUI wrapper around a Safari view controller. This takes theurl
of the current article and opens the web page.
That's it! Run your app again and test out the presentation of your WebView
by clicking on the "View Full Article" button. If you haven't already, feel free to create your own API key and add it to the Constants.swift
file to test your app against live data and get up-to-date news. If you're feeling up to it, check out the Bonus Functionality section in the README.md
.