Search code examples
iosswiftuiuikit

Can't dismiss sheet containing a font picker


When I scroll down to dismiss a sheet containing a UIFontPickerViewController, there is some extra space instead.

dismiss sheet

import UIKit
import SwiftUI

public struct SUIFontPicker: UIViewControllerRepresentable {
    
    @Environment(\.presentationMode) var presentationMode
    private let onFontPick: (UIFontDescriptor) -> Void
    
    public init(onFontPick: @escaping (UIFontDescriptor) -> Void) {
        self.onFontPick = onFontPick
    }
    
    public func makeUIViewController(context: UIViewControllerRepresentableContext<SUIFontPicker>) -> UIFontPickerViewController {
        let configuration = UIFontPickerViewController.Configuration()
        configuration.includeFaces = true
        configuration.displayUsingSystemFont = false
        
        let vc = UIFontPickerViewController(configuration: configuration)
        vc.delegate = context.coordinator
        return vc
    }
    
    public func makeCoordinator() -> SUIFontPicker.Coordinator {
        return Coordinator(self, onFontPick: self.onFontPick)
    }
    
    public class Coordinator: NSObject, UIFontPickerViewControllerDelegate {
        
        var parent: SUIFontPicker
        private let onFontPick: (UIFontDescriptor) -> Void
        
        
        init(_ parent: SUIFontPicker, onFontPick: @escaping (UIFontDescriptor) -> Void) {
            self.parent = parent
            self.onFontPick = onFontPick
        }
        
        public func fontPickerViewControllerDidPickFont(_ viewController: UIFontPickerViewController) {
            guard let descriptor = viewController.selectedFontDescriptor else { return }
            onFontPick(descriptor)
            parent.presentationMode.wrappedValue.dismiss()
        }
        
        public func fontPickerViewControllerDidCancel(_ viewController: UIFontPickerViewController) {
            parent.presentationMode.wrappedValue.dismiss()
        }
    }
    
    public func updateUIViewController(_ uiViewController: UIFontPickerViewController,
                                       context: UIViewControllerRepresentableContext<SUIFontPicker>) {
        
    }
}

struct FontPicker: View {

    @State var isFontPickerPresented = false

    @State var selectedFont: Font? = nil

    var body: some View {
        Button {
            isFontPickerPresented = true
        } label: {
            HStack {
                Text("Select font")
            }
        }
        .sheet(isPresented: $isFontPickerPresented) {
            SUIFontPicker { fontDescriptor in
                let size = UIFont.preferredFont(forTextStyle: .body).pointSize
                let newFont = UIFont(descriptor: fontDescriptor, size: size)
                print(newFont)
                selectedFont = Font(newFont)
            }
        }
        .font(selectedFont)
    }
}



struct FontPicker_Previews: PreviewProvider {
    static var previews: some View {
        Form {
            FontPicker()
            FontPicker()
        }
    }
}

I would like to know why this happens and whether the following effect can be achieved while using a .sheet modifier.

dismiss sheet

Here, present(fontPicker, animated: true) is used instead. There is automatically a "Choose Font" title, a "Cancel" button and the search bar is pinned.

import SwiftUI

class FontPickerViewController: UIViewController, UIFontPickerViewControllerDelegate {
    var fontName: String = "Helvetica"
    var fontPicker = UIFontPickerViewController()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        fontPicker.delegate = self
        let button = UIButton(type: .system)
        button.titleLabel?.font = .systemFont(ofSize: 17)
        button.frame = CGRect(x: 0, y: 0, width: 100, height: 30) // how to fit content?
        button.setTitle("Choose font", for: .normal)
        button.addTarget(self, action: #selector(showFontPicker(sender:)), for: .touchUpInside)
        view.addSubview(button)
    }
    func fontPickerViewControllerDidPickFont(_ viewController: UIFontPickerViewController) {
        dismiss(animated: true)
        guard let descriptor = viewController.selectedFontDescriptor else { return }
        let font = UIFont(descriptor: descriptor, size: 17)
        fontName = font.fontName
    }
    @objc func showFontPicker(sender: UIButton!) {
        let configuration = UIFontPickerViewController.Configuration()
        configuration.includeFaces = true
        fontPicker = UIFontPickerViewController(configuration: configuration)
        present(fontPicker, animated: true)
    }
}

struct FontPickerTest: UIViewControllerRepresentable {
    @Binding var fontName: String
    func makeUIViewController(context: Context) -> some UIViewController {
        let picker = FontPickerViewController()
        picker.fontName = fontName
        return picker
    }
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
}

struct TestPreview: View {
    var body: some View {
        Form {
            FontPickerTest(fontName: .constant(""))
        }
    }
}


struct FontPickerTest_Previews: PreviewProvider {
    static var previews: some View {
        TestPreview()
    }
}

I don't really know how to use a UIButton properly and I want to be a able to use a VStack or HStack to display the selected font.

struct FontPicker: View {

    @State var isFontPickerPresented = false

    @State var selectedFont: UIFont = UIFont.preferredFont(forTextStyle: .body)

    var body: some View {
        Button {
            isFontPickerPresented = true
        } label: {
            VStack(alignment: .leading) {
                Text("Select font")
                Text(selectedFont.fontName)
                    .font(.caption) // how to only change size without changing font family
            }
        }
        .sheet(isPresented: $isFontPickerPresented) {
            SUIFontPicker { fontDescriptor in
                let size = UIFont.preferredFont(forTextStyle: .body).pointSize
                let newFont = UIFont(descriptor: fontDescriptor, size: size)
                print(newFont)
                selectedFont = newFont
            }
        }
        .font(Font(selectedFont))
    }
}

Solution

  • A similar issue is reported here. The only solution there is to make the button that presents UIFontPickerViewController also in UIKit, as a VC that is just a UIButton.

    class FontPickerButtonController: UIViewController, UIFontPickerViewControllerDelegate {
        var onFontPick: ((UIFontDescriptor) -> Void)?
        
        var fontPicker = UIFontPickerViewController()
        
        override func loadView() {
            super.loadView()
            fontPicker.delegate = self
            let button = UIButton(type: .system)
            button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
            button.setTitle("Choose font", for: .normal)
            button.addTarget(self, action: #selector(showFontPicker), for: .touchUpInside)
            button.sizeToFit()
            button.backgroundColor = .clear
            view = button
        }
        
        func fontPickerViewControllerDidPickFont(_ viewController: UIFontPickerViewController) {
            dismiss(animated: true)
            guard let descriptor = viewController.selectedFontDescriptor else { return }
            onFontPick?(descriptor)
        }
        @objc func showFontPicker() {
            present(fontPicker, animated: true)
        }
    }
    

    We then wrap this in a UIViewControllerRepresentable. Since you want a SwiftUI Button in the Form, you can add a Binding<Bool> to the UIViewControllerRepresentable that can be set to true when the SwiftUI button is pressed.

    struct FontPicker: UIViewControllerRepresentable {
        @Binding var isPickerPresented: Bool
        
        public func makeCoordinator() -> Coordinator {
            return Coordinator(self, onFontPick: self.onFontPick)
        }
        
        public class Coordinator {
            
            var parent: FontPicker
            let picker = FontPickerButtonController()
            
            init(_ parent: FontPicker, onFontPick: @escaping (UIFontDescriptor) -> Void) {
                self.parent = parent
                picker.onFontPick = onFontPick
            }
        }
        
        var onFontPick: (UIFontDescriptor) -> Void
        
        func makeUIViewController(context: Context) -> FontPickerButtonController {
            context.coordinator.picker
        }
        func updateUIViewController(_ uiViewController: FontPickerButtonController, context: Context) {
            if isPickerPresented {
                context.coordinator.picker.showFontPicker()
                // semantically this should be set to false when the font picker is dismissed
                // I'm being a bit lazy here
                isPickerPresented = false
            }
        }
        
        func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: FontPickerButtonController, context: Context) -> CGSize? {
            uiViewController.view.intrinsicContentSize
        }
    }
    

    Usage:

    @State var showFontPicker = false
        
    var body: some View {
        Form {
            Button {
                showFontPicker = true
            } label: {
                VStack {
                    FontPicker(isPickerPresented: $showFontPicker) { desc in
                        fontFamily = desc.fontAttributes[.family] as? String
                    }
                    Text(fontFamily ?? "Not Selected")
                        .font(customCaptionFont)
                        .foregroundColor(.black)
                }
            }
        }
    }
    
    @State var fontFamily: String?
    
    var customCaptionFont: Font {
        if let fontFamily {
            return Font.custom(fontFamily, size: UIFont.preferredFont(forTextStyle: .caption1).pointSize, relativeTo: .caption)
        } else {
            return .caption
        }
    }
    

    Output:

    enter image description here