So I Wanted To Hide A UIView... Reactively

Life In Plastic, It’s Fantastic

Benjamin

--

Imagination, Life Is Your Creation

In the previous instalment, I implemented the following effect, as seen in the AirBNB app, using nothing more than UIKit and some basic math.

Right at the end, I mentioned that personally my final step, would be to implement it using ReactiveSwift. As is the norm when implementing a UI-effect that you've never attempted before, it's like rolling a pair of dice — it's trial and error. Anyone can “master” the code, but at the end of the day it needs to be visually tested by humans, to ensure that it works as expected. Most developers I know, including myself, usually need to tweak the code, run, and test it a few times before it’s “perfect.”

Come On Barbie, Let’s Go Party!

What I didn’t expect while using ReactiveSwift to achieve the goal, was that it would help me focus more on the data stream during scroll, and its manipulation, than I do when using UIKit. I leant heavily on reactive operators, and unit testing. It became clearer that, if the data, tests, and math was right, then the effect would be too. I felt less of a need to run, test, tweak, and re-run, than I usually do.

For the implementation, I first declared an Input and an Output on my view model. All my data will be funnelled into the Input, and the Output will be a simpleCGFloat value, which I’ll observe in my UIViewController. I’ll eventually use this value to mutate the constraint value on scroll.

protocol MyViewModelInputs {
typealias ScrollInfo(contentOffsetY: CGFloat, topConstraintConstant: CGFloat, heightOfViewToMove: CGFloat)
func scrollViewDidScroll(info: ScrollInfo)
}
protocol MyViewModelOutputs {
var scrollViewSignal: Signal<CGFloat, NoError> { get }
}

Oh, I’m Having So Much Fun!

Back in my UIViewController, I pass the data into my Input, using the same UIScrollViewDelegate method as last time. Only now, I can forego the need for anything other than scrollViewDidScroll.

extension ViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
viewModel.inputs.scrollViewDidScroll(info: (
contentOffsetY: scrollView.contentOffset.y,
topConstraintConstant: topConstraint.constant,
heightOfViewToMove: heightOfView
)
)
}
}

In the view model, the Output takes the data from the Input, and after employing the use of several operators — notably combinePrevious, filter and map— I’m left with the simple CGFloat value that I’ll observe later. The resulting Signal (output) looks a little like this:

scrollViewSignal = scrollViewDidScroll.output
.combinePrevious()
.filter {
// .. is not at top, or bottom ...
// .. other conditions ...
}
.map { previous, current in
let minimumConstantValue = CGFloat(-current.heightOfViewToMove)
let delta = current.contentOffsetY - previous.contentOffsetY
if delta < 0 {
return min(current.topConstraintConstant - delta, 0)
} else {
return max(minimumConstantValue, current.topConstraintConstant - delta)
}
}

Well Barbie, We’re Just Getting Started

Back in the UIViewController, I observe the Output, and mutate the constraint. Remember, that this value is now a simple CGFloat.

viewModel.outputs.scrollViewSignal.observeValues {
topConstraint.constant = $0
}

The app, if run, behaves as it did last time. Only now, it’s using ReactiveSwift.

Post Mortem

Before implementation even began, plenty of research was conducted into UIScrollViewDelegate and how it works. I discovered that the relevant methods were — luckily — called regardless of whether the contentOffset was changed manually, or programatically.

If they weren’t, then I’m sure the eventual solution to this problem would’ve wound up way, way more complicated! So, thanks UIKit-humans!

Of course, we need to be careful of UIKit behaviour changing in the future, so as always, tread carefully. Don’t make assumptions, and read the documentation carefully.

Thanks for reading!

If you liked this post, consider giving me a clap, or seven. このストーリーを好きだったら、拍手してくれませんか?

--

--