Last active
August 18, 2020 21:49
-
-
Save simme/1a40bb14cda5b9dbe1033f37618c860a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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 | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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