willdale / SwiftUICharts

A charts / plotting library for SwiftUI. Works on macOS, iOS, watchOS, and tvOS and has accessibility features built in.
MIT License
843 stars 105 forks source link

Attempting to Screenshot Chart in UIImage, LineChart's lines and points disappear? #189

Closed gesabo closed 2 years ago

gesabo commented 2 years ago

Using the code below I'm attempting to build a share feature to share the chart (the demo uses the LineChartDemoView from the example app) however when you try and capture the view (tap on the share button), the lines and points are missing on the SwiftUIChart in the UIImage. Is there a way around this issue? I thought it might be related to the animation but setting the animation to nil didn't change the behavior.

import SwiftUI

//construct enum to decide which sheet to present:
enum ActiveSheet: String, Identifiable { // <--- note that it's now Identifiable
    case photoLibrary, shareSheet
    var id: String {
        return self.rawValue
    }
}

struct ShareHomeView: View {

    @State private var shareCardAsImage: UIImage? = nil

    @State var activeSheet: ActiveSheet? = nil // <--- now an optional property

    var shareCard: some View {
        LineChartDemoView()
            .frame(height: 350)
    }
    var body: some View {
        NavigationView {
            VStack {
                shareCard
                Button(action: {

                    shareCardAsImage = shareCard.asImage()
                    self.activeSheet = .shareSheet

                }) {
                    HStack {
                        Image(systemName: "square.and.arrow.up")
                            .font(.system(size: 20))
                        Text("Share")
                            .font(.headline)
                    }
                    .frame(minWidth: 0, maxWidth: .infinity, minHeight: 50, maxHeight: 50)
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(20)
                }
                .padding(.horizontal)
            } //End of Master VStack
            //sheet choosing view to display based on selected enum value:
            .sheet(item: $activeSheet) { [shareCardAsImage] sheet in // <--- sheet is of type ActiveSheet and lets you present the appropriate sheet based on which is active
                switch sheet {
                case .photoLibrary:
                    Text("TODO")
                case .shareSheet:
                    if let unwrappedImage = shareCardAsImage {
                        ShareSheet(photo: unwrappedImage)
                    }

                }
            }
            //Needed to Wrap in a Navigation View and hide title so that dark mode would work, otherwise this sheet was always in the iPhone's light or dark mode
            .navigationBarHidden(true)
            .navigationTitle("")
        }
    }
}

struct RecoveryShareHomeView_Previews: PreviewProvider {
    static var previews: some View {
        ShareHomeView().preferredColorScheme(.dark)
        ShareHomeView().preferredColorScheme(.light)
    }
}

extension View {
    func asImage() -> UIImage {
        let controller = UIHostingController(rootView: self)

        // locate far out of screen
        controller.view.frame = CGRect(x: 0, y: CGFloat(Int.max), width: 1, height: 1)
        UIApplication.shared.keyWindow!.rootViewController?.view.addSubview(controller.view)

        let size = controller.sizeThatFits(in: UIScreen.main.bounds.size)
        controller.view.bounds = CGRect(origin: .zero, size: size)
        controller.view.sizeToFit()

        let image = controller.view.asImage()
        controller.view.removeFromSuperview()
        return image
    }
}

extension UIView {
    func asImage() -> UIImage {
        let renderer = UIGraphicsImageRenderer(bounds: bounds)
        return renderer.image { rendererContext in
            // [!!] Uncomment to clip resulting image
            //             rendererContext.cgContext.addPath(
            //                UIBezierPath(roundedRect: bounds, cornerRadius: 20).cgPath)
            //            rendererContext.cgContext.clip()

            // As commented by @MaxIsom below in some cases might be needed
            // to make this asynchronously, so uncomment below DispatchQueue
            // if you'd same met crash
            //            DispatchQueue.main.async {
            layer.render(in: rendererContext.cgContext)
            //            }
        }
    }
}

extension UIApplication {

    var keyWindow: UIWindow? {
        // Get connected scenes
        return UIApplication.shared.connectedScenes
            // Keep only active scenes, onscreen and visible to the user
            .filter { $0.activationState == .foregroundActive }
            // Keep only the first `UIWindowScene`
            .first(where: { $0 is UIWindowScene })
            // Get its associated windows
            .flatMap({ $0 as? UIWindowScene })?.windows
            // Finally, keep only the key window
            .first(where: \.isKeyWindow)
    }

}

import LinkPresentation

//This code is from https://gist.github.com/tsuzukihashi/d08fce005a8d892741f4cf965533bd56

struct ShareSheet: UIViewControllerRepresentable {
    let photo: UIImage

    func makeUIViewController(context: Context) -> UIActivityViewController {
        //let text = ""
        //let itemSource = ShareActivityItemSource(shareText: text, shareImage: photo)

        let activityItems: [Any] = [photo]

        let controller = UIActivityViewController(
            activityItems: activityItems,
            applicationActivities: nil)

        return controller
    }

    func updateUIViewController(_ vc: UIActivityViewController, context: Context) {

    }
}
zcjhnsn commented 2 years ago

@gesabo Did you ever figure this out? I'm running into the same issue

gesabo commented 2 years ago

@zcjhnsn no I never did. Ended up using another charts lib for the share function, would love to figure it out though!

zcjhnsn commented 2 years ago

@gesabo I got it to work but it feels hacky. Had to "disable" the chart animation using the .transaction modifier on the superview. Then used a DispatchQueue.main.asyncAfter block to wait to capture the screenshot until the animation finished. It'll be much cleaner once they officially add support to disable chart animation.

willdale commented 2 years ago

Hey @gesabo and @zcjhnsn, on branch https://github.com/willdale/SwiftUICharts/tree/189-screenshot-chart-in-uiimage-linecharts, is a proposed fix for it. Currently, it is only tested on Line Chart.

Feel free to have a go and offer feedback, I'll look to add in the other chart types and to make it work on iOS 14.


Below is the client side code based off of @gesabo.

Add .disableAnimation(chartData: data) to the view that will be rendered. - Probably add it just after the chart so it is at the top.

Bear in mind that the view to render doesn't have to be the view that is being displayed - you can tweak the view to be rendered as needed.

#if canImport(UIKit)
enum ActiveSheet: String, Identifiable {
    case photoLibrary, shareSheet
    var id: String {
        return self.rawValue
    }
}

struct ShareHomeView: View {

    var chartImageController = ChartImageController()
    @State var showImage = false
    @State var image = UIImage()
    @State var bag = Set<AnyCancellable>()

    @State private var shareCardAsImage: UIImage? = nil
    @State var activeSheet: ActiveSheet? = nil

    var shareCard: some View {
        LineChartDemoView()
            .frame(width: UIScreen.main.bounds.width,
                   height: UIScreen.main.bounds.height)
    }

    var body: some View {
        Button {
            let controller = ChartImageHostingController(rootView: shareCard)
            controller.finalImage
                .sink { completion in
                    self.chartImageController.controller = nil
                } receiveValue: { image in
                    self.shareCardAsImage = image
                    self.activeSheet = .shareSheet
                }
                .store(in: &bag)
            controller.start()
            chartImageController.controller = controller

        } label: {
            HStack {
                Image(systemName: "square.and.arrow.up")
                    .font(.system(size: 20))
                Text("Share")
                    .font(.headline)
            }
            .frame(minWidth: 0, maxWidth: .infinity, minHeight: 50, maxHeight: 50)
            .background(Color.blue)
            .foregroundColor(.white)
            .cornerRadius(20)
        }
        .padding(.horizontal)
        .sheet(item: $activeSheet) { [shareCardAsImage] sheet in
            switch sheet {
            case .photoLibrary:
                Text("TODO")
            case .shareSheet:
                if let unwrappedImage = shareCardAsImage {
                    ShareSheet(photo: unwrappedImage)
                }

            }
        }
    }
}

struct RecoveryShareHomeView_Previews: PreviewProvider {
    static var previews: some View {
        ShareHomeView().preferredColorScheme(.dark)
        ShareHomeView().preferredColorScheme(.light)
    }
}
#endif

import LinkPresentation

//This code is from https://gist.github.com/tsuzukihashi/d08fce005a8d892741f4cf965533bd56
struct ShareSheet: UIViewControllerRepresentable {
    let photo: UIImage

    func makeUIViewController(context: Context) -> UIActivityViewController {
        let activityItems: [Any] = [photo]
        return UIActivityViewController(
            activityItems: activityItems,
            applicationActivities: nil
        )
    }

    func updateUIViewController(_ vc: UIActivityViewController, context: Context) {

    }
}