Posted at

【iOS】SmartNewsみたいなUIをつくってみました

More than 3 years have passed since last update.


はじめに

iOS Second Stage Advent Calendar 12日目の記事です。

今回で3記事目です。

宜しくお願いします。


本題

今回はSmartNewsみたいなUIをサンプルでつくってみました。

以下のような感じです。

SampleSmartNewsUI.gif

ありきたりのUIPageViewControllerを使ったところではなくて、各ページの(縦の)スクロールに合わせてヘッダーの部分が動くところを実装してみました。

サンプルプロジェクトを GitHubにあげたので、よかったら見てみてください。

AdventCalendar2015/SampleSmartNewsUI at master · ryokosuge/AdventCalendar2015


細かい解説

プロジェクトをXcodeで見てもらえればすぐわかると思うのですが、ちょっとした構成を...。


Storyboard

以下のようになっています。

スクリーンショット 2015-12-10 22.11.07.png

ContainerViewのところにUIPageViewControllerviewaddSubview()しています。

ここら辺はソースコードを見た方が早いかと思います。


ソースコード


ViewController.swift

import UIKit

class ViewController: UIViewController {

@IBOutlet weak var progressView: UIView!
@IBOutlet weak var progressBarView: UIView!
@IBOutlet weak var progressTextLabel: UILabel!
@IBOutlet weak var navigationBar: UINavigationBar!
@IBOutlet weak var menuView: UIView!
@IBOutlet weak var containerView: UIView!

@IBOutlet weak var constProgressViewHeight: NSLayoutConstraint!
@IBOutlet weak var constMenuViewTopMargin: NSLayoutConstraint!
@IBOutlet weak var constProgressBarViewWidth: NSLayoutConstraint!

private var pageViewController: UIPageViewController? = nil

private let MaxPage: Int = 10
private let MaxContentInset = UIEdgeInsets(top: 88, left: 0, bottom: 0, right: 0)
private let DefaultProgressViewHeight: CGFloat = 64.0
private let PulledDownTextInterval: CGFloat = -70.0
private let ReleasedTextInterval: CGFloat = -120.0

private var currentIndex: Int = 0
private var isLoading: Bool = false {
didSet {
progressTextLabel.text = isLoading ? "更新中" : "Sample"
}
}

override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
setupView()
}

override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: true)
}

override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}

}

/// MARK: - PageViewController Delegate.
extension ViewController: ContentViewControllerDelegate {

func didScrollOffset(offset: CGPoint, pageIndex: Int) {
let offsetY = offset.y + MaxContentInset.top
let barHeight = navigationBar.frame.height
if offsetY < barHeight {
if offsetY <= 0 {
let progressViewHeight = DefaultProgressViewHeight - offsetY
constProgressViewHeight.constant = progressViewHeight
constMenuViewTopMargin.constant = 0
} else {
constMenuViewTopMargin.constant = -offsetY
constProgressViewHeight.constant = DefaultProgressViewHeight
}
} else {
constMenuViewTopMargin.constant = -navigationBar.frame.height
}

if !isLoading {
if offsetY < ReleasedTextInterval {
progressTextLabel.text = "はなして更新"
} else if offsetY < PulledDownTextInterval {
progressTextLabel.text = "引き下げて更新"
} else {
progressTextLabel.text = "Sample"
}
}
}

func didSelectIndexPath(indexPath: NSIndexPath, item: String) {
performSegueWithIdentifier("ShowDetail", sender: nil)
}

func didEndDraggingOffset(offset: CGPoint, pageIndex: Int) {
if !isLoading {
let offsetY = offset.y + MaxContentInset.top
if offsetY < ReleasedTextInterval {
fetch()
}
}
}
}

/// MARK: - UIContentViewController Delegate.
extension ViewController: UIPageViewControllerDelegate {

func pageViewController(pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if let viewController = pageViewController.viewControllers?.first as? ContentViewController {
currentIndex = viewController.index
}
}

}

/// MARK: - UIPageViewController DataSource
extension ViewController: UIPageViewControllerDataSource {

func pageViewController(pageViewController: UIPageViewController, viewControllerBeforeViewController viewController: UIViewController) -> UIViewController? {
let beforeIndex = currentIndex - 1
return viewControllerAtIndex(beforeIndex)
}

func pageViewController(pageViewController: UIPageViewController, viewControllerAfterViewController viewController: UIViewController) -> UIViewController? {
let afterIndex = currentIndex + 1
return viewControllerAtIndex(afterIndex)
}

}

/// MARK: - private methods.
extension ViewController {

private func fetch() {
isLoading = true
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0)) {[weak self] () -> Void in
var progress: CGFloat = 0.0
while (progress < 100.0) {
let rand = CGFloat(Int(arc4random_uniform(10)))
progress += rand
dispatch_async(dispatch_get_main_queue(), {[weak self] () -> Void in
self?.updateProgressBarPercentage((progress / 100.0))
})
NSThread.sleepForTimeInterval(0.05)
}
dispatch_async(dispatch_get_main_queue(), {[weak self] () -> Void in
self?.updateProgressBarPercentage(progress)
})

dispatch_async(dispatch_get_main_queue(), {[weak self] () -> Void in
self?.finihed()
})

}
}

private func finihed() {
isLoading = false
UIView.animateWithDuration(0.3, animations: {[weak self] () -> Void in
self?.progressBarView.alpha = 0.0
}) {[weak self] (finished) -> Void in
self?.constProgressBarViewWidth.constant = 0
self?.progressBarView.alpha = 1.0
}
}

private func updateProgressBarPercentage(percentage: CGFloat) {
let progress = progressView.frame.width * percentage
constProgressBarViewWidth.constant = progress
}

/// Viewに関することを処理する
private func setupView() {
/// navigationControllerのnavigationBarは非表示にする
navigationController?.setNavigationBarHidden(true, animated: false)

/// 自前のnavigationBarの設定
navigationBar.translucent = true
navigationBar.setBackgroundImage(UIImage(), forBarMetrics: UIBarMetrics.Default)
navigationBar.shadowImage = UIImage()

let pageViewController = UIPageViewController(transitionStyle: UIPageViewControllerTransitionStyle.Scroll, navigationOrientation: UIPageViewControllerNavigationOrientation.Horizontal, options: nil)
pageViewController.willMoveToParentViewController(self)
pageViewController.delegate = self
pageViewController.dataSource = self
pageViewController.view.frame = containerView.bounds
pageViewController.view.autoresizingMask = [.FlexibleHeight, .FlexibleWidth]
containerView.addSubview(pageViewController.view)
addChildViewController(pageViewController)
pageViewController.didMoveToParentViewController(self)
self.pageViewController = pageViewController

if let viewController = viewControllerAtIndex(currentIndex) {
pageViewController.setViewControllers([viewController], direction: .Forward, animated: false, completion: nil)
}
}

private func viewControllerAtIndex(index: Int) -> ContentViewController? {

if index < 0 || MaxPage < index {
return nil
}

let viewController = ContentViewController.instantiateViewControllerAtIndex(index)
viewController.delegate = self
viewController.setInset(MaxContentInset)
viewController.setOffset(CGPoint(x: 0, y: -MaxContentInset.top))

return viewController
}
}


UIPageViewControllerで表示しているViewはこんな感じです。


ContentViewController

import UIKit

protocol ContentViewControllerDelegate: class {

func didScrollOffset(offset: CGPoint, pageIndex: Int)
func didSelectIndexPath(indexPath: NSIndexPath, item: String)
func didEndDraggingOffset(offset: CGPoint, pageIndex: Int)

}

class ContentViewController: UIViewController {

static func instantiateViewControllerAtIndex(index: Int) -> ContentViewController {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let viewController = storyboard.instantiateViewControllerWithIdentifier("ContentViewController") as! ContentViewController
viewController.index = index
return viewController
}

@IBOutlet weak var tableView: UITableView!
weak var delegate: ContentViewControllerDelegate? = nil

private let maxCellCount = 100
private(set) var index: Int = 0
private var items: [String] = []

private var _offset: CGPoint? = nil
private var _inset: UIEdgeInsets? = nil

override func viewDidLoad() {
super.viewDidLoad()

// Do any additional setup after loading the view.
setupView()
createItems()
}

override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)

if let o = _offset {
tableView.contentOffset = o
_offset = nil
}

if let indexPathForSelectedRow = tableView.indexPathForSelectedRow {
tableView.deselectRowAtIndexPath(indexPathForSelectedRow, animated: true)
}
}

override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}

}

/// MARK : - public methods.
extension ContentViewController {

func setOffset(offset: CGPoint) {
if let t = tableView {
t.contentOffset = offset
} else {
_offset = offset
}
}

func setInset(inset: UIEdgeInsets) {
if let t = tableView {
t.contentInset = inset
} else {
_inset = inset
}
}
}

extension ContentViewController: UITableViewDataSource {

func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 1
}

func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return items.count
}

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let string = items[indexPath.row]
let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath)
cell.textLabel?.text = string
return cell
}
}

extension ContentViewController: UITableViewDelegate {

func scrollViewDidScroll(scrollView: UIScrollView) {
delegate?.didScrollOffset(scrollView.contentOffset, pageIndex: index)
let insetTop = _inset?.top ?? 0
if scrollView.contentOffset.y < -insetTop {
let inset = insetTop - (insetTop + scrollView.contentOffset.y)
tableView.scrollIndicatorInsets.top = inset
} else {
tableView.scrollIndicatorInsets.top = insetTop
}
}

func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {
delegate?.didEndDraggingOffset(scrollView.contentOffset, pageIndex: index)
}

func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let item = items[indexPath.row]
delegate?.didSelectIndexPath(indexPath, item: item)
}
}

/// MARK: - private methods.
extension ContentViewController {

private func setupView() {
tableView.delegate = self
tableView.dataSource = self
tableView.tableFooterView = UIView()

if let i = _inset {
tableView.contentInset = i
tableView.scrollIndicatorInsets = i
}

if let o = _offset {
tableView.contentOffset = o
}
}

private func createItems() {
self.items = (0..<maxCellCount).map({ "cell \($0)"})
}

}


という感じです。


補足

簡単に説明します。

イベントの始まりはContentViewControllertableViewのscrollイベントです。

scrollViewDidScroll(scrollView: UIScrollView)が起きたらdelegateViewControllerにイベントを渡しています。

で、ContentViewControllerDelegatedidScrollOffset(offset: CGPoint, index: Int)からスクロールしたオフセットを計算して各Viewを動かしているという形です。


終わりに

最終的にはプロジェクトをみて動かしてもらえると嬉しいです。

AdventCalendar2015/SampleSmartNewsUI at master · ryokosuge/AdventCalendar2015

以上になります。