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.xcodeprojfile 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.MyNametocom.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
Viewsfolder calledCarouselView.swift
- To create a new file in the
Viewsfolder, right-click on the folder and selectNew File... - Filter the file types for
SwiftUI Viewand select that file type - Name the file
CarouselView.swift, make sure theNewsfeedUItarget is selected in theTargetslist, and clickCreate
- Insert a
varat the top of theCarouselViewstruct calledarticlesthat is of type[NewsArticle]
struct CarouselView: View {
var articles: [NewsArticle]
articleswill store an array ofNewsArticle's that should be displayed in theCarouselView.
- Update the
PreviewProviderto 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 oursampleDataand pass them to theCarouselViewin the preview.
- Replace the
Textin thebodywith aTabViewthat contains thetitleof each article in thearticlesarray:
var body: some View {
TabView() {
ForEach(articles) { article in
Text(article.title)
}
}
.aspectRatio(3 / 2, contentMode: .fit)
.tabViewStyle(PageTabViewStyle())
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
}- The
ForEachloop goes through thearticlesarray and creates aTextview for eacharticlein the array. For now, thearticle'stitleis the only thing displayed in theCarouselView. - A
TabViewis aViewthat can switch between different child views. In our case, the child views are the different article titles displayed byText(article.title). TheTabViewis the most important piece of ourCarouselViewsince it's what allows us to swipe between different featuredNewsArticle's. .aspectRatio(3 / 2, contentMode: .fit)sets the aspect ratio of ourTabViewto 3:2 (width:height). By setting.contentModeto.fitwe tell theTabViewto 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 ourTabViewappear as a page view (or "carousel") with highlighted dots to track our tab position. Without this style, ourTabViewwould 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
CarouselViewlooks 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 + Pto 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
Viewsfolder calledFeatureView.swift
- To create a new file in the
Viewsfolder, right-click on the folder and selectNew File... - Filter the file types for
SwiftUI Viewand select that file type - Name the file
FeatureView.swift, make sure theNewsfeedUItarget is selected in theTargetslist, and clickCreate
- Insert a
varat the top of theFeatureViewstructcalledarticlethat is of typeNewsArticle:
struct FeatureView: View {
var article: NewsArticle
- This will contain the
NewsArticlethat thisFeatureViewwill display.
- Update the
PreviewProviderto initialize the preview with a sampleNewsArticle
struct FeatureView_Previews: PreviewProvider {
static var previews: some View {
FeatureView(article: NewsFeed.sampleData[0])
}
}- Replace the
TextView in thebodywith aRemoteImageView for the currentarticle:
var body: some View {
RemoteImage(url: article.urlToImage)
.aspectRatio(3 / 2, contentMode: .fit)
}- We use a
RemoteImageto download and display the image for thisarticle. If you haven't added your API key toConstants.swift,RemoteImagewon't make any network requests. Instead, it displays a random image from ourAssets.xcassetscatalog. If you do however add your API key,RemoteImagewill 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 theRemoteImageand resizes it to fit into a 3:2 frame.
- In the same file, add a new
TextOverlayViewthat will overlay theNewsArticle'stitleon top of theRemoteImage. This newViewshould go after theFeatureViewstruct, 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 textwill contain the text that is displayed by this overlay. In our app, this will contain the article'stitle.gradientis 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 whitetextto stand out on top of anyRemoteImage.- The
bodyof theTextOverlayis aZStackthat places thetexton top of thegradient. Both of these views are aligned using their.bottomLeading(bottom-left) corners. - The
Texthas some standard.padding()applied to all edges, as well as 25 points of additional.paddingon the.bottomedge. This creates enough space at the bottom so ourtextand the highlighted dots of ourCarouselViewdon't overlap. .foregroundColor(.white)sets the color of ourtextto.white.
- Add a
TextOverlayto 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
FeatureViewlooks like now! You can use the shortcutCMD + OPT + Pto 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.swiftonce more and replace theTextinside of theForEachloop with aFeatureView. You can also remove the.indexViewStylemodifier.CarouselView.swiftshould 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
Viewsfolder calledCategoryRow.swift -
Insert two
vars at the top of theCategoryRowstruct calledcategoryNameandarticles:
struct CategoryRow: View {
var categoryName: String
var articles: [NewsArticle]
categoryNameis the name of this category ("Technology", "Business", "Sports", "Music", etc.)articlesis anArrayofNewsArticles that belong to this category
- Update the
PreviewProviderto initialize these twovars in the preview:
struct CategoryRow_Previews: PreviewProvider {
static var articles = NewsFeed.sampleData
static var previews: some View {
CategoryRow(categoryName: "Sports", articles: articles)
}
}- Update the
Textin thebodyto display thecategoryNamerather than the static"Hello, World!"text:
var body: some View {
Text(categoryName)
.font(.title)
}- We use
.font(.title)to style thisTextas a.title.
- Add an
HStack(horizontal stack) View below theTextView 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 thealignmentto be.top, meaning that all of the items in theHStackwill have their top edges aligned. - The
ForEachloop goes through thearticlesarray and creates aTextfor eacharticlein the array. For now, theTextonly 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
.leadingalignment to align all of the vertically stacked content to the leading (left) edge. - This
VStackwill 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
HStackdoesn'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
HStackin 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
categoryNamesome breathing room on the.topand.leading(left) edges. - Wrapping the
HStackin aScrollViewallows the content in theHStackto stretch out and take up as much space as it needs! Now the article titles aren't squished together. - Our
ScrollViewspecifies.horizontal, meaning it only scrolls horizontally. showsIndicators: falsemeans that theScrollViewwon'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 theCategoryRowin your Canvas. You can use your cursor to swipe theScrollViewand 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 CategoryItems 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
Viewsfolder calledCategoryItem.swift -
Add a
varcalledarticlethat will contain the specificNewsArticleto display on this card:
struct CategoryItem: View {
var article: NewsArticle
- When we initialize a new
CategoryItemwe will provide the specificNewsArticleto display in the card.
- Update the
PreviewProviderto initialize theCategoryItemwith a sampleNewsArticle:
struct CategoryItem_Previews: PreviewProvider {
static var previews: some View {
CategoryItem(article: NewsFeed.sampleData[0])
}
}- Replace the
bodyof 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
VStackis just like the one we used inCategoryRow. It contains aRemoteImageView and aTextView below it. TheRemoteImagedisplays the thumbnail for thearticle, and theTextdisplays thearticle'stitle.
NOTE: If you never added your API key to
Constants.swift,RemoteImagewill pull from our locally saved images. Otherwise,RemoteImagewill 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 thetitlefrom 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 entireVStackto a width of 155pt, so that none of thearticle'stitlewill 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
.paddingto the.leading(left) edge of theVStack. When theCategoryItems 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 aCategoryItemthat displays the currentarticle. YourCategoryRow.swiftshould 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. ThisViewis the first screen that a user sees when they open the app for the first time. -
Add the following property to the
ContentViewstruct:
struct ContentView: View {
@ObservedObject var newsFeed = NewsFeed()
@ObservedObjectis a property wrapper. It tells ourContentViewto observe the state of thenewsFeedand react to any changes. This means that when thenewsFeedchanges, any views that depend on it will be reloaded. This happens when our app finishes fetching news articles and loads them into thenewsFeed.NewsFeedis 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 existingTextinside of aNavigationViewand give it a.navigationTitle:
var body: some View {
NavigationView {
Text("Hello, World!")
.navigationTitle("Newsfeed")
}
}NavigationViewis 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, thisNavigationViewwill allow us to navigate to new screens when we tap on different articles.
- Next, replace the
Textwith aListthat contains just theCarouselViewfor 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
Listis 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
CarouselViewto theList, we check to see if!newsFeed.general.isEmpty. We do this because we need at least 1 article innewsFeed.generalin 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
ifstatement to make sure the category has at least 1 article. If there is at least 1 article, we add aCategoryRowto theList. Otherwise, we don't add anything to theListfor the empty category. - The
CategoryRow's are given acategoryNameand a list ofarticlesto display. For instance, theCategoryRowfor Sports is given the name"Sports"and thenewsFeed.sportsarticles. This uses theCategoryRowthat we built earlier to display all of the Sports articles in a horizontalScrollView. This is the same for the other categories as well. - Each
CategoryRowuses.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, eachCategoryRowshould 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
Viewsfolder calledDetailView.swift -
Add a
varcalledarticlethat will contain the specificNewsArticleto display on this page:
struct DetailView: View {
var article: NewsArticle
- When we initialize a new
DetailViewwe will provide the specificNewsArticleto display in the page.
- Update the
PreviewProviderto initialize theDetailViewwith a sampleNewsArticle:
struct DetailView_Previews: PreviewProvider {
static var previews: some View {
DetailView(article: NewsFeed.sampleData[4]) // using 4 to get good representative data- Replace
bodywith 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
VStackwithleadingalignment andpaddingis added to wrap the following views: - The
TextView is updated to use thearticle.titleand modifiers are added foritalictype,titlefont, andsemiboldfont weight. RemoteImageis added underneath the title withbottompadding and anaspectRatioset tofitto fill or shrink the image to fit the screen's width.- We add another
TextView for the article's author withboldweight 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.authoris nil. - Next, the published date is added in a
TextView withsubheadlinefont andbottompadding. - Followed by another
TextView withbodyfont andbottompadding. Notice how we need to use the nil coalescing operator again to provide a default value ifarticle.descriptionis nil. - The last view added is a
Buttonusing 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 includingtitle3font, 10 points ofverticaland 50 points ofhorizontalpadding to stretch the button lengthwise,whiteforegroundColorfor the button text,bluebackgroundcolor, and acornerRadiusof 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
ButtonStyleprotocol. Use the code below to add the newFilledButtonStylestruct directly after yourDetailViewstruct, 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
ButtonStylewe need to implement themakeBodyfunction which takes aButtonStyleConfigurationparameter. This simply allows us to access properties relevant to the button such aslabelandisPressed. - We add all of our styling we had before to the
configuration.labelproperty. - Note: we take advantage of the
configuration.isPressedproperty 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 theButtonis in itspressedstate.
- You'll notice that the
Buttonis left-aligned due to the alignment of its parentVStack. We can remedy this by embedding theButtoninside anHStackand addingSpacerviews 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
DetailViewto extend past the bottom of the screen. Let's wrap up the UI by embedding ourVStackinside aScrollViewwith a.navigationBarTitleDisplayMode(.inline)modifier (this makes the nav bar compact when we enter theDetailViewfrom 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: FeatureViews in the main carousel, and CategoryItems in the category rows.
- Open up
CarouselView.swiftand embed theFeatureViewinside aNavigationLinkwithDetailView(article: article)as the destination. Add the.buttonStyle(PlainButtonStyle())style modifier to theNavigationLinkto remove the default blue text color.
ForEach(articles) { article in
NavigationLink(destination: DetailView(article: article)) {
FeatureView(article: article)
}.buttonStyle(PlainButtonStyle())
}NavigationLinkis 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.swiftand embed theCategoryIteminside aNavigationLinkwithDetailView(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.swiftand 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 theCategoryRows 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.swiftand add a newvarunderarticlecalledshowWebViewwith the@Stateproperty wrapper and assign it tofalse.
struct DetailView: View {
var article: NewsArticle
@State var showWebView = false
@Stateis a property wrapper that signifies the source of truth for theshowWebViewvalue 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
HStackcontaining 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()
}showWebViewis set totrueinside theButtonaction closure. This allows the web view to be presented only after theButtonis pressed.sheet(isPresented:onDismiss:content:)modally presents the givencontentview whenisPresentedis true.- We pass in
$showWebViewfor theisPresentedparameter.$showWebViewis a binding, or a shared property to ourshowWebViewstate variable. This is set totrueby theDetailViewwhen the button is pressed, and set tofalseagain by thesheeton 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
contentis our pre-definedWebViewwhich is just a SwiftUI wrapper around a Safari view controller. This takes theurlof 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.





















