Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Solution #1

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## User settings
xcuserdata/
xcshareddata/

## App packaging
*.ipa
*.dSYM.zip
*.dSYM

.build/

# CocoaPods
Pods/
229 changes: 228 additions & 1 deletion CampaignBrowser.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

47 changes: 4 additions & 43 deletions CampaignBrowser/Base.lproj/Main.storyboard
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
<capability name="Constraints to layout margins" minToolsVersion="6.0"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
Expand All @@ -31,44 +28,8 @@
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center">
<rect key="frame" x="0.0" y="0.0" width="375" height="283"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="h3t-mf-lUQ">
<rect key="frame" x="8" y="8" width="359" height="267"/>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="YiC-pw-PH8" customClass="LabelWithPadding" customModule="CampaignBrowser" customModuleProvider="target">
<rect key="frame" x="8" y="202.5" width="60.5" height="36.5"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="calibratedRGB"/>
<fontDescription key="fontDescription" name="HelveticaNeue-Bold" family="Helvetica Neue" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="justified" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="iKV-MD-vzF" customClass="LabelWithPadding" customModule="CampaignBrowser" customModuleProvider="target">
<rect key="frame" x="8" y="239" width="359" height="28"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="calibratedRGB"/>
<edgeInsets key="layoutMargins" top="8" left="20" bottom="8" right="8"/>
<fontDescription key="fontDescription" name="HoeflerText-Regular" family="Hoefler Text" pointSize="12"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</view>
<constraints>
<constraint firstAttribute="trailingMargin" secondItem="h3t-mf-lUQ" secondAttribute="trailing" id="0Qv-Ly-s5x"/>
<constraint firstAttribute="trailingMargin" secondItem="iKV-MD-vzF" secondAttribute="trailing" id="4FB-dA-Inc"/>
<constraint firstItem="iKV-MD-vzF" firstAttribute="leading" secondItem="Kec-Ca-5D8" secondAttribute="leadingMargin" id="760-sQ-xsE"/>
<constraint firstAttribute="bottomMargin" secondItem="iKV-MD-vzF" secondAttribute="bottom" constant="8" id="AgS-3w-y75"/>
<constraint firstItem="YiC-pw-PH8" firstAttribute="leading" secondItem="Kec-Ca-5D8" secondAttribute="leadingMargin" id="PgD-gW-tZP"/>
<constraint firstItem="iKV-MD-vzF" firstAttribute="top" secondItem="YiC-pw-PH8" secondAttribute="bottom" id="QqG-b8-yhQ"/>
<constraint firstItem="h3t-mf-lUQ" firstAttribute="top" secondItem="Kec-Ca-5D8" secondAttribute="topMargin" id="bUW-eS-iVs"/>
<constraint firstAttribute="bottomMargin" secondItem="h3t-mf-lUQ" secondAttribute="bottom" id="uaV-3P-jj4"/>
<constraint firstItem="h3t-mf-lUQ" firstAttribute="leading" secondItem="Kec-Ca-5D8" secondAttribute="leadingMargin" id="wC4-Bv-2Cj"/>
</constraints>
<size key="customSize" width="375" height="283"/>
<connections>
<outlet property="descriptionLabel" destination="iKV-MD-vzF" id="Zvs-Xr-6X8"/>
<outlet property="imageView" destination="h3t-mf-lUQ" id="MKZ-D5-97E"/>
<outlet property="nameLabel" destination="YiC-pw-PH8" id="zNK-GY-IKJ"/>
</connections>
</collectionViewCell>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="loadingIndicatorCell" id="SZh-Y0-q4r">
<rect key="frame" x="0.0" y="293" width="375" height="225"/>
Expand All @@ -78,7 +39,7 @@
<autoresizingMask key="autoresizingMask"/>
<subviews>
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" animating="YES" style="whiteLarge" translatesAutoresizingMaskIntoConstraints="NO" id="o42-h4-YUU">
<rect key="frame" x="169" y="94.5" width="37" height="37"/>
<rect key="frame" x="169" y="94" width="37" height="37"/>
<color key="color" red="0.32549019610000002" green="0.68627450980000004" blue="0.59999999999999998" alpha="1" colorSpace="calibratedRGB"/>
</activityIndicatorView>
</subviews>
Expand Down
38 changes: 38 additions & 0 deletions CampaignBrowser/CampaignListingCellSizeCalculator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import UIKit

/**
Calculator used to determine the size of dynamic `CampaignCell`
*/
class CampaignListingCellSizeCalculator {
func size(
for campaign: CampaignListingView.Campaign,
_ collectionView: UICollectionView
) -> CGSize {

let width = collectionView.frame.width

let imageHeight = collectionView.frame.width / style.campaignCell.imageViewAspectRatio

let labelWidth = width - style.campaignCell.labelHorizontalPadding * 2

let titleHeight: CGFloat = campaign.name.height(
withConstrainedWidth: labelWidth,
font: style.font.title
)

let descriptionHeight: CGFloat = campaign.description.height(
withConstrainedWidth: labelWidth,
font: style.font.subtitle
)

let height = imageHeight
+ titleHeight
+ style.campaignCell.labelInterlinePadding
+ descriptionHeight

return CGSize(
width: width,
height: height
)
}
}
18 changes: 18 additions & 0 deletions CampaignBrowser/CampaignListingFetcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import RxSwift

/**
Responsible for fetching campaign listings.
*/
protocol CampaignListingFetcher {
func fetchCampaigns() -> Observable<CampaignList>
}

/**
Live implementation of the `CampaignListingFetcher`.
*/
class CampaignListingFetcherLive: CampaignListingFetcher {
func fetchCampaigns() -> Observable<CampaignList> {
ServiceLocator.instance.networkingService
.createObservableResponse(request: CampaignListingRequest())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ class ListingDataSource: NSObject, UICollectionViewDataSource, UICollectionViewD

/** The campaigns that need to be displayed. */
let campaigns: [CampaignListingView.Campaign]

lazy var sizeCalculator = CampaignListingCellSizeCalculator()

/**
Designated initializer.
Expand Down Expand Up @@ -81,13 +83,11 @@ class ListingDataSource: NSObject, UICollectionViewDataSource, UICollectionViewD

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: collectionView.frame.size.width, height: 450)
sizeCalculator.size(for: campaigns[indexPath.item], collectionView)
}

}



/**
The data source for the `CampaignsListingView` which is used while the actual contents are still loaded.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ class CampaignListingViewController: UIViewController {
super.viewWillAppear(animated)

// Load the campaign list and display it as soon as it is available.
ServiceLocator.instance.networkingService
.createObservableResponse(request: CampaignListingRequest())
ServiceLocator.instance.campaignFetcher
.fetchCampaigns()
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self] campaigns in
guard let self = self else { return }
Expand Down
92 changes: 89 additions & 3 deletions CampaignBrowser/Screens/CampaignsListing/Cells/CampaignCell.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import UIKit
import RxSwift
import SnapKit

/**
Style structure that may be used throughout the project to share styling variables.
*/
struct Style {
struct Font {
let title = UIFont(name: "Helvetica Neue Bold", size: 18)!
let subtitle = UIFont(name: "Hoefler Text", size: 12)!
}
let font = Font()
let campaignCell = CampaignCellStyle()
}

var style = Style()

struct CampaignCellStyle {
let imageViewAspectRatio: CGFloat = 4.0 / 3
let labelInterlinePadding: CGFloat = 8
let labelHorizontalPadding: CGFloat = 8
}

/**
The cell which displays a campaign.
Expand All @@ -10,13 +30,13 @@ class CampaignCell: UICollectionViewCell {
private let disposeBag = DisposeBag()

/** Used to display the campaign's title. */
@IBOutlet private(set) weak var nameLabel: UILabel!
private(set) weak var nameLabel: UILabel!

/** Used to display the campaign's description. */
@IBOutlet private(set) weak var descriptionLabel: UILabel!
private(set) weak var descriptionLabel: UILabel!

/** The image view which is used to display the campaign's mood image. */
@IBOutlet private(set) weak var imageView: UIImageView!
private(set) weak var imageView: UIImageView!

/** The mood image which is displayed as the background. */
var moodImage: Observable<UIImage>? {
Expand Down Expand Up @@ -45,9 +65,75 @@ class CampaignCell: UICollectionViewCell {

override func awakeFromNib() {
super.awakeFromNib()
setupView()

assert(nameLabel != nil)
assert(descriptionLabel != nil)
assert(imageView != nil)
}
}

private extension CampaignCell {

var cellStyle: CampaignCellStyle {
CampaignBrowser.style.campaignCell
}

func setupView() {
setupImageView()
setupTitleLabel()
setupDescriptionLabel()
}

func setupImageView() {
let v = UIImageView()
contentView.addSubview(v)

let aspectRatio: CGFloat = cellStyle.imageViewAspectRatio

v.snp.makeConstraints { make in
make.left.equalToSuperview()
make.right.equalToSuperview()
make.top.equalToSuperview()
make.height.equalTo(contentView.snp.width).dividedBy(aspectRatio)
}

v.contentMode = .scaleAspectFill

imageView = v
}

func setupTitleLabel() {
let l = UILabel()
contentView.addSubview(l)

l.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom)
make.left.equalToSuperview().offset(cellStyle.labelHorizontalPadding)
make.right.equalToSuperview().offset(-cellStyle.labelHorizontalPadding)
}

l.font = style.font.title
l.numberOfLines = 2
l.textAlignment = .natural

nameLabel = l
}

func setupDescriptionLabel() {
let l = UILabel()
contentView.addSubview(l)

l.snp.makeConstraints { make in
make.top.equalTo(nameLabel.snp.bottom).offset(cellStyle.labelInterlinePadding)
make.left.equalToSuperview().offset(cellStyle.labelHorizontalPadding)
make.right.equalToSuperview().offset(-cellStyle.labelHorizontalPadding)
}

l.font = style.font.subtitle
l.numberOfLines = 0
l.textAlignment = .justified

descriptionLabel = l
}
}
10 changes: 8 additions & 2 deletions CampaignBrowser/Services/NetworkingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,17 @@ protocol Request {
var url: URL { get }
}


/**
Responsible for handling the networking communication.
*/
class NetworkingService {
protocol NetworkingService {
func createObservableResponse<R: Request>(request: R) -> Observable<R.ResponseData>
}

/**
Live implementation of the `NetworkingService`.
*/
class NetworkingServiceLive: NetworkingService {

/** The URLSession instance which is used to handle the request. */
private let urlSession: URLSession
Expand Down
4 changes: 3 additions & 1 deletion CampaignBrowser/Services/ServiceLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ class ServiceLocator {

lazy var imageService: ImageService = ImageService()

lazy var networkingService = NetworkingService(urlSession: URLSession.shared)
lazy var networkingService: NetworkingService = NetworkingServiceLive(urlSession: URLSession.shared)

lazy var campaignFetcher: CampaignListingFetcher = CampaignListingFetcherLive()
}
19 changes: 19 additions & 0 deletions CampaignBrowser/String+Font+Size.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation
import UIKit


extension String {
func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [.font : font], context: nil)

return ceil(boundingBox.height)
}

func width(withConstrainedHeight height: CGFloat, font: UIFont) -> CGFloat {
let constraintRect = CGSize(width: .greatestFiniteMagnitude, height: height)
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [.font : font], context: nil)

return ceil(boundingBox.width)
}
}
36 changes: 36 additions & 0 deletions CampaignBrowserTests/CampaignListingViewTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
@testable import CampaignBrowser
import XCTest
import SnapshotTesting

class CampaignBrowserTests: XCTestCase {

private var view: CampaignListingViewController!

private var fetcher: CampaignFetcherMock {
ServiceLocator.instance.campaignFetcher as! CampaignFetcherMock
}

override func setUp() {
ServiceLocator.instance.campaignFetcher = CampaignFetcherMock()
view = UIStoryboard(name: "Main", bundle: nil)
.instantiateInitialViewController() as CampaignListingViewController?
}

func test_snapshot() {
fetcher.testData = .normal

assertSnapshot(matching: view, as: .image(on: .iPhoneXsMax))
}

func test_snapshot_longTitleHas2Lines() {
fetcher.testData = .longText

assertSnapshot(matching: view, as: .image(on: .iPhoneXsMax))
}

func test_snapshot_shortText() {
fetcher.testData = .shortText

assertSnapshot(matching: view, as: .image(on: .iPhoneXsMax))
}
}
Loading