richardtop / CalendarKit

📅 Calendar for Apple platforms in Swift
https://www.youtube.com/watch?v=cJ63-_z1qg8
MIT License
2.52k stars 340 forks source link

Layout Issue when using UISplitViewController #320

Closed niklasgrewe closed 2 years ago

niklasgrewe commented 3 years ago

Hi, as i descriped on stackoverflow, i can't using CalendarKit inside UISplitViewController When i set the CalendarViewController as primary viewcontroller, the result looks like this:

Bildschirmfoto 2021-10-03 um 14 47 52

As you can see, the CalendarViewController fits the iPhone Screen, but not the primary column on the iPad (SplitView). How can i solve this issue?

I am using the CalendarKitApp Template and modified the SceneDelegate like this:

var splitView: UISplitViewController?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
    // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
    // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
    self.makeSplitViewController()

    if let windowScene = scene as? UIWindowScene {
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = self.splitView
        self.window = window
        window.makeKeyAndVisible()

        self.splitView?.viewController(for: .secondary)?.navigationController?.navigationBar.barStyle = .black
    }
}

func makeSplitViewController() {
    let splitViewController = UISplitViewController(style: .doubleColumn)
    splitViewController.preferredDisplayMode = .oneBesideSecondary

    let primaryViewController = UINavigationController(rootViewController: CalendarViewController())
    let secondaryViewController = UIHostingController(rootView: EventDetailView()) // -> SwiftUI View

    splitViewController.setViewController(primaryViewController, for: .primary)
    splitViewController.setViewController(secondaryViewController, for: .secondary)
    splitViewController.setViewController(primaryViewController, for: .compact)

    self.splitView = splitViewController
}
niklasgrewe commented 3 years ago

what I also noticed is the fact that the CalendarViewController is displayed correctly when I put it in a UIViewControllerRepresentable like this:

// CalendarDayView.swift

import SwiftUI
import CalendarKit
import EventKit

struct CalendarDayView: UIViewControllerRepresentable {

    func makeUIViewController(context: Context) -> CalendarViewController {
        let dayView = CalendarViewController()

        var style = CalendarStyle()
        style.header.backgroundColor = .white

        dayView.updateStyle(style)

        return dayView
    }

    func updateUIViewController(_ dayView: CalendarViewController, context: Context) {

    }

    typealias UIViewControllerType = CalendarViewController
}

inside my SceneDelegate

let primaryViewController = UIHostingController(rootView: CalendarDayView())

The result looks like this

Bildschirmfoto 2021-10-03 um 15 24 21

I have lost my NavigationBarStyles and also the NavigationTitle is not displayed, but the layout fits. I don't understand why this works with UIHostingController and not before....

richardtop commented 3 years ago

Hi, looks like there are some layout inconsistencies. Please, debug the following line: https://github.com/richardtop/CalendarKit/blob/master/Source/DayView.swift#L126

I think there are some issues in the way the layout code is called in one example versus another. Finding that inconsistency is the key to resolving your issue.

Regarding the Stackoverflow post, I don't see the connection between this issue and the compact/regular layout. If there were one, we'd see iPad layout for the calendar which we clearly don't see.

compact/regular switching code: https://github.com/richardtop/CalendarKit/blob/master/Source/Header/DaySelector/DaySelector.swift#L150

Initialize header views (1,2,3,4,5 // mon,tue,wed...): https://github.com/richardtop/CalendarKit/blob/master/Source/Header/DaySelector/DaySelector.swift#L82

So I'm quite certain that there is some inconsistency in the layout / framing / autoresizing masks code.

If you're planning to debug this issue, please post your findings here, as I'm also interested in the solution.

niklasgrewe commented 3 years ago

@richardtop thank you very much for your detailed answer. I absolutely agree with you that the error is most likely in the layout and not in the size classes.

I would love to find this inconsistency. As you already described, I would have to change this part in the code:

https://github.com/richardtop/CalendarKit/blob/master/Source/DayView.swift#L126

Frankly, I wouldn't know what to change in the code. After all, the layout problems occur exclusively in the sidebar. I just can't understand why the problems don't occur when i wrapped in a UIHostingController . What does it do differently than if I use the CalendarViewController directly?

I would appreciate your help and support because I don't really know how can i solve this, without using a UIHostingController instead....

niklasgrewe commented 3 years ago

after same research i found this on a medium blog post.

After choosing the doubleColumn style in Interface Builder and running my app I noticed that for some reason the whole master view controller was clipped. The layout of the main menu view controller was created years ago when Safe Area layout guides were not present. Hence, the collection view was constrained to its superview, instead of Safe Area, which caused this weird clipping. After enabling Safe Area layout guides in File inspector everything went back to normal.

https://medium.com/swlh/ios-14-uisplitviewcontroller-5-issues-that-you-may-run-into-65b09601b3fb - Clipped master view controller’s view

could this be related?

richardtop commented 3 years ago

Yeah, this could be related. Please try debugging this issue by putting a breakpoint / print statements to the layout code and comparing 'frame' values when CK is ran without and with hosting view controller. This might give us some insights.

niklasgrewe commented 3 years ago

as I suspected... the frame values look like this:

let primaryViewController = UINavigationController(rootViewController: CalendarViewController())
// result: dayHeaderView Frame: (0.0, 0.0, 420.0, 88.0)
// result: timelinePagerView Frame: (0.0, 88.0, 420.0, 672.0)

let primaryViewController = UIHostingController(rootView: CalendarView())
// result: dayHeaderView Frame: (0.0, 0.0, 320.0, 88.0)
// result: timelinePagerView Frame: (0.0, 88.0, 320.0, 652.0)

As it seems the UIHostingController sets some layout contraints, so the width is at 320.0 and not at 420.0. It is interesting that all values are correct, except for the width and the height, only for timelinePagerView. The only question is... how do we manage - just like the UIHostingController - to manually set these layout contraints. Would you have any idea?

Test-Repo: https://github.com/niklasgrewe/CalendarKit-iPadOS

niklasgrewe commented 3 years ago

Apparently the bug also has something in common with the new SplitViewController that was added in iOS 14...

In my app (Adaptivity) I deliberately want to be able to show the user how the Classic style split view controller behaves because it is different. For example, in a double/triple column split view the primary view controller is actually wider than what appears on screen (420 points instead of 320) with a leading margin which is 100 points larger than the trailing margin to cancel out the effect.

Using classic style of UISplitViewController in Xcode 12 - https://developer.apple.com/forums/thread/655195?answerId=622814022#622814022

niklasgrewe commented 3 years ago

i also found this:

The primary view controller is wider than what is visible on screen to provide extra content when over-scrolling during a swipe action. It is 420 points wide, but 320 points is visible. So content that is aligned with the leading edge instead of the leading margin (or safe area, as you noted), will be partially off-screen.

https://hacknicity.medium.com/the-primary-view-controller-is-wider-than-what-is-visible-on-screen-to-provide-extra-content-when-ed00ca003acf

So is there a way to fix it maybe? If yes, what we need to change?

richardtop commented 3 years ago

Try taking a look at both cases with View Hierarchy debugger and make sure to show clipped views as well. Then we'll be able to see what's going on: https://developer.apple.com/library/archive/documentation/ToolsLanguages/Conceptual/Xcode_Overview/ExaminingtheViewHierarchy.html

niklasgrewe commented 3 years ago

after some try and error i found a solution to fix the layout issues using UISplitViewController in the DayView.swift file i changed the layoutSubviews() function as follows:

override public func layoutSubviews() {
  super.layoutSubviews()

  if #available(iOS 11.0, *) {
      dayHeaderView.translatesAutoresizingMaskIntoConstraints = false
      timelinePagerView.translatesAutoresizingMaskIntoConstraints = false

      dayHeaderView.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor).isActive = true
      dayHeaderView.trailingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.trailingAnchor).isActive = true
      dayHeaderView.topAnchor.constraint(equalTo: self.safeAreaLayoutGuide.topAnchor).isActive = true
      dayHeaderView.heightAnchor.constraint(equalToConstant: headerHeight).isActive = true

      timelinePagerView.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor).isActive = true
      timelinePagerView.trailingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.trailingAnchor).isActive = true
      timelinePagerView.topAnchor.constraint(equalTo: dayHeaderView.bottomAnchor).isActive = true
      timelinePagerView.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true

      self.transitionToHorizontalSizeClass(traitCollection.horizontalSizeClass)

  }
}

The key is to use safe area layouts instead of frames. The transitionToHorizontalSizeClass() function is also needed at this point so that the layout it also updated correctly in SplitView (two apps side by side in different sizes).

If you want, I'm happy to create a PR so others can benefit from it as well. Otherwise, I can leave the changes on my end.

That's all I've found out so far and I hope you can do something with it. Thanks for your help and tips

richardtop commented 3 years ago

Thanks for the solution. Btw, SafeAreaInsets are available in the frame-based layout too. The only problem in your solution is that it's being executed on layoutSubviews, while the constraints installation should happen once during (this) view life cycle. I'll take a look and a bit more and fix this issue.

Frame-based layout is a legacy of the initial implementation of the CalendarKit, your solution is definitely a better one.