Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion WMF Framework/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,6 @@ public class Colors: NSObject {
}
}


@objc(WMFTheme)
public class Theme: NSObject {

Expand Down Expand Up @@ -894,3 +893,12 @@ public protocol Themeable: AnyObject {
@objc(applyTheme:)
func apply(theme: Theme) // this might be better as a var theme: Theme { get set } - common VC superclasses could check for viewIfLoaded and call an update method in the setter. This would elminate the need for the viewIfLoaded logic in every applyTheme:
}

// Use for SwiftUI environment objects
public final class ObservableTheme: ObservableObject {
@Published public var theme: Theme

public init(theme: Theme) {
self.theme = theme
}
}
106 changes: 106 additions & 0 deletions Wikipedia.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

80 changes: 80 additions & 0 deletions Wikipedia/Code/DemoShiftingThreeLineHeaderView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import UIKit

class DemoShiftingThreeLineHeaderView: ShiftingTopView, Themeable {

private(set) var theme: Theme

private lazy var headerView: ThreeLineHeaderView = {
let view = ThreeLineHeaderView()
view.topSmallLine.text = "Test 1"
view.middleLargeLine.text = "Test 2"
view.bottomSmallLine.text = "Test 3"
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()

private var topConstraint: NSLayoutConstraint?

init(shiftOrder: Int, theme: Theme) {
self.theme = theme
super.init(shiftOrder: shiftOrder)
}

required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func setup() {
super.setup()

addSubview(headerView)

let top = headerView.topAnchor.constraint(equalTo: topAnchor)
let bottom = bottomAnchor.constraint(equalTo: headerView.bottomAnchor)
let leading = headerView.leadingAnchor.constraint(equalTo: leadingAnchor)
let trailing = trailingAnchor.constraint(equalTo: headerView.trailingAnchor)

NSLayoutConstraint.activate([
top,
bottom,
leading,
trailing
])

self.topConstraint = top
clipsToBounds = true
apply(theme: theme)
}

// MARK: Overrides

override var contentHeight: CGFloat {
return headerView.frame.height
}

private var isFullyHidden: Bool {
return -(topConstraint?.constant ?? 0) == contentHeight
}

override func shift(amount: CGFloat) -> ShiftingTopView.AmountShifted {

let limitedShiftAmount = max(0, min(amount, contentHeight))

let percent = limitedShiftAmount / contentHeight
alpha = 1.0 - percent

if (self.topConstraint?.constant ?? 0) != -limitedShiftAmount {
self.topConstraint?.constant = -limitedShiftAmount
}

return limitedShiftAmount
}

// MARK: Themeable

func apply(theme: Theme) {
self.theme = theme
backgroundColor = theme.colors.paperBackground
headerView.apply(theme: theme)
}
}
117 changes: 117 additions & 0 deletions Wikipedia/Code/ShiftingNavigationBarView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import UIKit

class ShiftingNavigationBarView: ShiftingTopView, Themeable {

private let navigationItems: [UINavigationItem]
private weak var popDelegate: UIViewController? // Navigation back actions will be forwarded to this view controller

private var topConstraint: NSLayoutConstraint?

private lazy var bar: UINavigationBar = {
let bar = UINavigationBar()
bar.translatesAutoresizingMaskIntoConstraints = false
bar.delegate = self
return bar
}()

init(shiftOrder: Int, navigationItems: [UINavigationItem], popDelegate: UIViewController) {
self.navigationItems = navigationItems
self.popDelegate = popDelegate
super.init(shiftOrder: shiftOrder)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func setup() {
super.setup()

bar.setItems(navigationItems, animated: false)
addSubview(bar)

let top = bar.topAnchor.constraint(equalTo: topAnchor)
let bottom = bottomAnchor.constraint(equalTo: bar.bottomAnchor)
let leading = bar.leadingAnchor.constraint(equalTo: leadingAnchor)
let trailing = trailingAnchor.constraint(equalTo: bar.trailingAnchor)

NSLayoutConstraint.activate([
top,
bottom,
leading,
trailing
])

self.topConstraint = top

clipsToBounds = true
}

// MARK: Overrides

override var contentHeight: CGFloat {
return bar.frame.height
}

private var isFullyHidden: Bool {
return -(topConstraint?.constant ?? 0) == contentHeight
}

override func shift(amount: CGFloat) -> ShiftingTopView.AmountShifted {

// Only allow navigation bar to move just out of frame
let limitedShiftAmount = max(0, min(amount, contentHeight))

// Shrink and fade
let percent = limitedShiftAmount / contentHeight
let barScaleTransform = CGAffineTransformMakeScale(1.0 - (percent/2), 1.0 - (percent/2))
for subview in self.bar.subviews {
for subview in subview.subviews {
subview.transform = barScaleTransform
}
}
alpha = 1.0 - percent

// Shift Y placement
if (self.topConstraint?.constant ?? 0) != -limitedShiftAmount {
self.topConstraint?.constant = -limitedShiftAmount
}

return limitedShiftAmount
}

// MARK: Themeable

func apply(theme: Theme) {
bar.setBackgroundImage(theme.navigationBarBackgroundImage, for: .default)
bar.titleTextAttributes = theme.navigationBarTitleTextAttributes
bar.isTranslucent = false
bar.barTintColor = theme.colors.chromeBackground
bar.shadowImage = theme.navigationBarShadowImage
bar.tintColor = theme.colors.chromeText
}
}

// MARK: Themeable

extension ShiftingNavigationBarView: UINavigationBarDelegate {
func navigationBar(_ navigationBar: UINavigationBar, shouldPop item: UINavigationItem) -> Bool {
popDelegate?.navigationController?.popViewController(animated: true)
return false
}

// Taken from NavigationBar.swift
func navigationBar(_ navigationBar: UINavigationBar, didPop item: UINavigationItem) {
// During iOS 14's long press to access back history, this function is called *after* the unneeded navigationItems have been popped off.
// However, with our custom navBar the actual articleVC isn't changed. So we need to find the articleVC for the top navItem, and pop to it.
// This should be in `shouldPop`, but as of iOS 14.0, `shouldPop` isn't called when long pressing a back button. Once this is fixed by Apple,
// we should move this to `shouldPop` to improve animations. (Update: A bug tracker was filed w/ Apple, and this won't be fixed anytime soon.
// Apple: "This is expected behavior. Due to side effects that many clients have in the shouldPop handler, we do not consult it when using the back
// button menu. We instead recommend that you hide the back button when you wish to disallow popping past a particular point in the navigation stack.")
if let topNavigationItem = navigationBar.items?.last,
let navController = popDelegate?.navigationController,
let tappedViewController = navController.viewControllers.first(where: {$0.navigationItem == topNavigationItem}) {
popDelegate?.navigationController?.popToViewController(tappedViewController, animated: true)
}
}
}
48 changes: 48 additions & 0 deletions Wikipedia/Code/ShiftingScrollView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import SwiftUI

struct ShiftingScrollView<Content: View>: View {

@EnvironmentObject var data: ShiftingTopViewsData

let axes: Axis.Set
let showsIndicators: Bool
let content: Content

init(
axes: Axis.Set = .vertical,
showsIndicators: Bool = true,
@ViewBuilder content: () -> Content
) {
self.axes = axes
self.showsIndicators = showsIndicators
self.content = content()
}

var body: some View {
ScrollView(axes, showsIndicators: showsIndicators) {
ZStack(alignment:.topLeading) {
GeometryReader { geometry in
Color.clear.preference(
key: ScrollOffsetPreferenceKey.self,
value: geometry.frame(in: .named("scrollView")).origin
)
}
.frame(width: 0, height: 0)
content
.padding(.top, data.totalHeight)
}
}
.coordinateSpace(name: "scrollView")
.onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: offsetChanged)
}

func offsetChanged(_ offset: CGPoint) {
data.scrollAmount = -offset.y
}
}

private struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero

static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}
43 changes: 43 additions & 0 deletions Wikipedia/Code/ShiftingTopView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation
import CocoaLumberjackSwift

class ShiftingTopView: SetupView {
typealias AmountShifted = CGFloat

weak var stackView: ShiftingTopViewsStack?
let shiftOrder: Int

init(shiftOrder: Int) {
self.shiftOrder = shiftOrder
super.init(frame: .zero)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func setup() {
super.setup()

translatesAutoresizingMaskIntoConstraints = false
}

override func layoutSubviews() {
super.layoutSubviews()

if stackView == nil {
DDLogError("Missing stackView assignment in ShiftingSubview, which could potentially cause incorrect content inset/padding calculations.")
}
stackView?.calculateTotalHeight()
}

var contentHeight: CGFloat {
assertionFailure("Must override")
return 0
}

func shift(amount: CGFloat) -> AmountShifted {
assertionFailure("Must override")
return 0
}
}
65 changes: 65 additions & 0 deletions Wikipedia/Code/ShiftingTopViewsContaining.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation
import SwiftUI

protocol ShiftingTopViewsContaining: UIViewController {
var shiftingTopViewsStack: ShiftingTopViewsStack? { get set }
}

// Note: This subclass only seems necessary for proper nav bar hiding in iOS 14 & 15. It can be removed and switched to raw UIHostingControllers for iOS16+
private class NavigationBarHidingHostingVC<Content: View>: UIHostingController<Content> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to prevent the bar from hiding when VoiceOver is on or provide another way for VoiceOver users to go back from this view when it is hiding (This can be done on the part 2 PR).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch, I hadn't thought about Voice Over yet. 😅 I'll spin it off into a Part 3 PR since Part 2 was getting pretty big.


override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)

self.navigationController?.isNavigationBarHidden = true
}
}

extension ShiftingTopViewsContaining {
func setup(shiftingTopViews: [ShiftingTopView], swiftuiView: some View, observableTheme: ObservableTheme) {

navigationController?.isNavigationBarHidden = true

let shiftingTopViewsStack = ShiftingTopViewsStack()

// Add needed environment objects to SwiftUI view, then embed hosting view controller, then embed SwiftUI hosting view controller
let finalSwiftUIView = swiftuiView
.environmentObject(shiftingTopViewsStack.data)
.environmentObject(observableTheme)

let childHostingVC: UIViewController

if #available(iOS 16, *) {
childHostingVC = UIHostingController(rootView: finalSwiftUIView)
} else {
childHostingVC = NavigationBarHidingHostingVC(rootView: finalSwiftUIView)
}

childHostingVC.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(childHostingVC.view)

NSLayoutConstraint.activate([
view.safeAreaLayoutGuide.topAnchor.constraint(equalTo: childHostingVC.view.topAnchor),
view.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: childHostingVC.view.leadingAnchor),
view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: childHostingVC.view.trailingAnchor),
view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: childHostingVC.view.bottomAnchor)
])

addChild(childHostingVC)
childHostingVC.didMove(toParent: self)
childHostingVC.view.backgroundColor = .clear

// Add shiftingTopViewsStack
view.addSubview(shiftingTopViewsStack)

NSLayoutConstraint.activate([
view.safeAreaLayoutGuide.topAnchor.constraint(equalTo: shiftingTopViewsStack.topAnchor),
view.leadingAnchor.constraint(equalTo: shiftingTopViewsStack.leadingAnchor),
view.trailingAnchor.constraint(equalTo: shiftingTopViewsStack.trailingAnchor)
])

shiftingTopViewsStack.addShiftingTopViews(shiftingTopViews)
shiftingTopViewsStack.apply(theme: observableTheme.theme)
self.shiftingTopViewsStack = shiftingTopViewsStack
}
}
7 changes: 7 additions & 0 deletions Wikipedia/Code/ShiftingTopViewsData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Foundation
import Combine

class ShiftingTopViewsData: ObservableObject {
@Published var scrollAmount = CGFloat(0)
@Published var totalHeight = CGFloat(0)
}
Loading