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

## はじめに

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

今回で3記事目です。

宜しくお願いします。

## 本題

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

以下のような感じです。

![SampleSmartNewsUI.gif](https://qiita-image-store.s3.amazonaws.com/0/47302/7a74c751-7dd3-9caa-6aa7-f43588c73de6.gif)

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

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

[AdventCalendar2015/SampleSmartNewsUI at master · ryokosuge/AdventCalendar2015](https://github.com/ryokosuge/AdventCalendar2015/tree/master/SampleSmartNewsUI)

## 細かい解説

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

### Storyboard

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

<img width="309" alt="スクリーンショット 2015-12-10 22.11.07.png" src="https://qiita-image-store.s3.amazonaws.com/0/47302/7c8db6d8-4d2e-8780-7ff6-2136f23c784c.png">

`ContainerView`のところに`UIPageViewController`の`view`を`addSubview()`しています。

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

### ソースコード

```swift: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はこんな感じです。

```swift: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)"})
    }
    
}
```

という感じです。

## 補足

簡単に説明します。

イベントの始まりは`ContentViewController`の`tableView`のscrollイベントです。

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

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

## 終わりに

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

[AdventCalendar2015/SampleSmartNewsUI at master · ryokosuge/AdventCalendar2015](https://github.com/ryokosuge/AdventCalendar2015/tree/master/SampleSmartNewsUI)

以上になります。