Skip to content

Instantly share code, notes, and snippets.

@simme simme/ParallaxHeader.swift
Last active Dec 10, 2018

Embed
What would you like to do?
//
// ParallaxHeader.swift
// MealPlanUI
//
// Created by Simon Ljungberg on 2017-11-15.
// Copyright © 2017 Filibaba. All rights reserved.
//
import Foundation
import UIKit
/**
The parallax header view is a view that can be added to a scroll view to create a stretching and shrinking view at the
top of the scroll view.
*/
public class ParallaxHeaderView: UIView {
// MARK: Properties
/// The main header image.
public let image: UIImage
/// The height of the header view.
public var height: CGFloat {
didSet {
if let scrollView = scrollView {
adjustScrollViewInset(scrollView: scrollView)
}
layoutContentView()
}
}
public var extraTopMargin: CGFloat = 0 {
didSet {
if extraTopMargin != oldValue, let scrollView = scrollView {
adjustScrollViewInset(scrollView: scrollView)
}
}
}
/// The minimum height of the parallax view. Set to the height of a navbar to make it emulate a navbar when scrolled
/// far enough.
public var minimumHeight: CGFloat = 0 {
didSet {
layoutContentView()
}
}
/// The "progress threshold" at which the image should start to be blurred, set to 1 to disable blurring.
public var blurFadeThreshold: CGFloat = 0.6
/// A weak reference to our parent scroll view.
private weak var scrollView: UIScrollView?
/// An obvservation token for the scroll view's content offset.
private var observation: NSKeyValueObservation?
// MARK: Initializer
/**
Initialize a new parallax header view with the given title, subtitle and image.
- Parameter image: The image to display in the parallax header.
- Parameter height: The height of the header view.
- Returns: A new parallax header.
*/
public init(image: UIImage, height: CGFloat) {
self.image = image
self.height = height
super.init(frame: .zero)
addSubview(blurredImageView)
addSubview(effectView)
addSubview(imageView)
}
/**
Restore a previously encoded parallax header view.
- Parameter aDecoder: A decoder used to decode an encoded parallax header view.
- Returns: A new parallax header object restored from previously encoded view.
*/
public required init?(coder aDecoder: NSCoder) {
guard let decodedImage = aDecoder.decodeObject(forKey: "image") as? UIImage else {
fatalError("Failed to decode image.")
}
image = decodedImage
height = CGFloat(aDecoder.decodeFloat(forKey: "height"))
super.init(coder: aDecoder)
}
// MARK: Encoding
/**
Encodes the parallax header into a restorable state.
*/
public override func encode(with aCoder: NSCoder) {
aCoder.encode(image, forKey: "image")
aCoder.encode(height, forKey: "height")
}
// MARK: View Lifecycle
public override func willMove(toSuperview newSuperview: UIView?) {
scrollView = nil
observation?.invalidate()
observation = nil
}
public override func didMoveToSuperview() {
guard let scrollView = self.superview as? UIScrollView else {
return
}
self.scrollView = scrollView
adjustScrollViewInset(scrollView: scrollView)
observation = scrollView.observe(
\.contentOffset,
options: [.initial, .new],
changeHandler: observeContentOffsetChange
)
scrollView.add(view: self)
insertConstraints()
}
// MARK: Views
private lazy var effectView: UIVisualEffectView = {
let effect = UIBlurEffect(style: .light)
let view = UIVisualEffectView(effect: effect).forAutoLayout()
return view
}()
private lazy var imageView: UIImageView = self.makeImageView()
private lazy var blurredImageView: UIImageView = self.makeImageView()
private func makeImageView() -> UIImageView {
let view = UIImageView(image: self.image).forAutoLayout()
view.contentMode = .scaleAspectFill
view.backgroundColor = .red
view.setContentHuggingPriority(.required, for: .vertical)
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
view.clipsToBounds = true
view.alpha = 0.4
return view
}
// MARK: Private Implementation
/**
Adjusts the scroll view's insets to match the parallax header's height.
- Parameter scrollView: The scroll view to adjust insets for.
*/
private func adjustScrollViewInset(scrollView: UIScrollView) {
var insets = scrollView.contentInset
insets.top = height + extraTopMargin
scrollView.contentInset = insets
var offset = scrollView.contentOffset
offset.y += insets.top - height
scrollView.contentOffset = offset
}
/**
When we're removed from our superview constraints related to ourselves are reset. This method adds them again.
*/
private func insertConstraints() {
self.backgroundColor = .black
blurredImageView.lockFrame(withView: self)
effectView.lockFrame(withView: self)
imageView.lockFrame(withView: self)
}
/// Observe the contentOffset property of the scroll view and trigger layout.
private func observeContentOffsetChange(_ scrollView: UIScrollView, _ offset: NSKeyValueObservedChange<CGPoint>) {
layoutContentView()
}
/**
Performs layout of our parallax header.
*/
private func layoutContentView() {
guard let scrollView = scrollView else { return }
let minHeight = min(self.minimumHeight, self.height) - extraTopMargin
let relativeYOffset = scrollView.contentOffset.y + scrollView.contentInset.top - height
let contentHeight = -relativeYOffset
let newFrame = CGRect(
x: 0,
y: relativeYOffset,
width: scrollView.frame.width,
height: max(contentHeight, minHeight)
)
frame = newFrame
// superview?.bringSubview(toFront: self)
let progress = 1 - (newFrame.height - self.minimumHeight) / (height - minimumHeight)
if progress > blurFadeThreshold {
let subProgress = (progress - blurFadeThreshold) / (1 - blurFadeThreshold)
imageView.alpha = max(min(1, 1 - subProgress), 0)
} else if progress < -blurFadeThreshold/2 {
// let subProgress = min(progress * -1, 1)
// imageView.alpha = max(min(1, 1 - subProgress), 0)
} else {
imageView.alpha = 1
}
}
}
// Create it like so:
private lazy var headerImageView: ParallaxHeaderView? = {
guard let headerImage = self.viewModel.headerImage else { return nil }
let parallax = ParallaxHeaderView(image: headerImage, height: self.view.bounds.width)
parallax.minimumHeight = view.safeAreaInsets.top
return parallax
}()
// Add it to the subview
if let headerImageView = headerImageView {
collectionView.addSubview(headerImageView)
}
// Update parallax if safe area changes
public override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
headerImageView?.minimumHeight = view.safeAreaInsets.top
headerImageView?.extraTopMargin = view.safeAreaInsets.top
}
// Hide real navigation bar upon appearing, but re-enable interactive gestures.
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
navigationController?.interactivePopGestureRecognizer?.delegate = self
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.