はじめに
iOS Second Stage Advent Calendar 12日目の記事です。
今回で3記事目です。
宜しくお願いします。
本題
今回はSmartNewsみたいなUIをサンプルでつくってみました。
以下のような感じです。
ありきたりのUIPageViewController
を使ったところではなくて、各ページの(縦の)スクロールに合わせてヘッダーの部分が動くところを実装してみました。
サンプルプロジェクトを GitHubにあげたので、よかったら見てみてください。
AdventCalendar2015/SampleSmartNewsUI at master · ryokosuge/AdventCalendar2015
細かい解説
プロジェクトをXcodeで見てもらえればすぐわかると思うのですが、ちょっとした構成を...。
Storyboard
以下のようになっています。
![スクリーンショット 2015-12-10 22.11.07.png](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.amazonaws.com%2F0%2F47302%2F7c8db6d8-4d2e-8780-7ff6-2136f23c784c.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&s=a0b178394406f7d34697f5e655500b82)
ContainerView
のところにUIPageViewController
のview
をaddSubview()
しています。
ここら辺はソースコードを見た方が早いかと思います。
ソースコード
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はこんな感じです。
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)"})
}
}
という感じです。
補足
簡単に説明します。
イベントの始まりはContentViewController
のtableView
のscrollイベントです。
scrollViewDidScroll(scrollView: UIScrollView)
が起きたらdelegate
でViewController
にイベントを渡しています。
で、ContentViewControllerDelegate
のdidScrollOffset(offset: CGPoint, index: Int)
からスクロールしたオフセットを計算して各Viewを動かしているという形です。
終わりに
最終的にはプロジェクトをみて動かしてもらえると嬉しいです。
AdventCalendar2015/SampleSmartNewsUI at master · ryokosuge/AdventCalendar2015
以上になります。