diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Demos/BottomSheetDemoController.swift b/ios/FluentUI.Demo/FluentUI.Demo/Demos/BottomSheetDemoController.swift index 9456e671ac..37d536ac4a 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo/Demos/BottomSheetDemoController.swift +++ b/ios/FluentUI.Demo/FluentUI.Demo/Demos/BottomSheetDemoController.swift @@ -91,6 +91,14 @@ class BottomSheetDemoController: DemoController { bottomSheetViewController?.preferredExpandedContentHeight = sender.isOn ? 0 : 400 } + @objc private func toggleTrailingEdge(_ sender: BooleanCell) { + bottomSheetViewController?.anchoredEdge = sender.isOn ? .trailing : .center + } + + @objc private func togglePreferredWidth(_ sender: BooleanCell) { + bottomSheetViewController?.preferredWidth = sender.isOn ? 400 : 0 + } + @objc private func showTransientSheet() { let hostingVC = UIHostingController(rootView: BottomSheetDemoListContentView()) @@ -249,7 +257,15 @@ class BottomSheetDemoController: DemoController { DemoItem(title: "Hide collapsed content", type: .boolean, action: #selector(toggleCollapsedContentHiding), isOn: collapsedContentHidingEnabled), DemoItem(title: "Flexible sheet height", type: .boolean, action: #selector(toggleFlexibleSheetHeight), isOn: bottomSheetViewController?.isFlexibleHeight ?? false), DemoItem(title: "Use custom handle accessibility label", type: .boolean, action: #selector(toggleHandleUsingCustomAccessibilityLabel), isOn: isHandleUsingCustomAccessibilityLabel), - DemoItem(title: "Full screen sheet content", type: .boolean, action: #selector(toggleFullScreenSheetContent), isOn: bottomSheetViewController?.preferredExpandedContentHeight == 0) + DemoItem(title: "Full screen sheet content", type: .boolean, action: #selector(toggleFullScreenSheetContent), isOn: bottomSheetViewController?.preferredExpandedContentHeight == 0), + DemoItem(title: "Attach to trailing edge", + type: .boolean, + action: #selector(toggleTrailingEdge), + isOn: bottomSheetViewController?.anchoredEdge == .trailing), + DemoItem(title: "Set preferred width to 400", + type: .boolean, + action: #selector(togglePreferredWidth), + isOn: bottomSheetViewController?.preferredWidth == 400) ], [ DemoItem(title: "Show transient sheet", type: .action, action: #selector(showTransientSheet)) diff --git a/ios/FluentUI/Bottom Sheet/BottomSheetController.swift b/ios/FluentUI/Bottom Sheet/BottomSheetController.swift index 801bce9ee9..2e535a89d8 100644 --- a/ios/FluentUI/Bottom Sheet/BottomSheetController.swift +++ b/ios/FluentUI/Bottom Sheet/BottomSheetController.swift @@ -39,6 +39,13 @@ public protocol BottomSheetControllerDelegate: AnyObject { case transitioning // Sheet is between states, only used during user interaction / animation } +/// Defines where the sheet should be postionioned relative to the screen space +@objc(MSFBottomSheetAnchorEdge) public enum BottomSheetAnchorEdge: Int { + case center // Sheet is centered on the screen + case leading // Sheet is constrained to the leading edge + case trailing // Sheet is constrained to the trailing edge +} + @objc(MSFBottomSheetController) public class BottomSheetController: UIViewController, Shadowable, TokenizedControlInternal { @@ -223,6 +230,28 @@ public class BottomSheetController: UIViewController, Shadowable, TokenizedContr } } + /// Setting this property will result in the sheet trying to be as close to this width as possible. + /// If the declared width is too large it will roll back to the maximum width + @objc open var preferredWidth: CGFloat = 0 { + didSet { + let shouldInvalidateLayout = (preferredWidth != oldValue) && isViewLoaded + if shouldInvalidateLayout { + view.setNeedsLayout() + } + } + } + + /// Represents where the sheet should appear on the screen. + /// Defaults to being centered + @objc open var anchoredEdge: BottomSheetAnchorEdge = .center { + didSet { + guard anchoredEdge != oldValue && isViewLoaded else { + return + } + view.setNeedsLayout() + } + } + /// When enabled, users will be able to move the sheet to the hidden state by swiping down. @objc open var allowsSwipeToHide: Bool = false @@ -721,8 +750,8 @@ public class BottomSheetController: UIViewController, Shadowable, TokenizedContr // Source of truth for the sheet frame at a given offset from the top of the root view bounds. // The output is only meaningful once view.bounds is non-zero i.e. a layout pass has occured. private func sheetFrame(offset: CGFloat) -> CGRect { - let availableWidth: CGFloat = view.bounds.width - let sheetWidth = shouldAlwaysFillWidth ? availableWidth : min(Constants.maxSheetWidth, availableWidth) + let sheetWidth: CGFloat = determineSheetWidth() + let sheetHeight: CGFloat if isFlexibleHeight { @@ -732,8 +761,50 @@ public class BottomSheetController: UIViewController, Shadowable, TokenizedContr sheetHeight = expandedSheetHeight } - return CGRect(origin: CGPoint(x: (view.bounds.width - sheetWidth) / 2, y: offset), - size: CGSize(width: sheetWidth, height: sheetHeight)) + // Calculates the location to put the left edge of the sheet relative to the view + // For right aligned we get the width of the view offset by the sheets width and the padding + // For left aligned we only need to add in the padding + // For center aligned we need the position of the center offset by half the sheets width + let xPosition: CGFloat + let isLeftToRight: Bool = UIView.userInterfaceLayoutDirection(for: view.semanticContentAttribute) == .leftToRight + switch anchoredEdge { + case .center: + xPosition = (view.bounds.width - sheetWidth) / 2 + case .leading: + if isLeftToRight { + xPosition = Constants.horizontalSheetPadding + } else { + xPosition = view.bounds.width - sheetWidth - Constants.horizontalSheetPadding + } + case .trailing: + if isLeftToRight { + xPosition = view.bounds.width - sheetWidth - Constants.horizontalSheetPadding + } else { + xPosition = Constants.horizontalSheetPadding + } + } + + let frame = CGRect(origin: CGPoint(x: xPosition, y: offset), + size: CGSize(width: sheetWidth, height: sheetHeight)) + return frame + } + + // Helper function to determine how wide the sheet should be + private func determineSheetWidth() -> CGFloat { + // Width will the fill the screen if should always fill width + // Otherwise we will try and set the size to the preferred width as long as its between the max and min width + // If its not between those we will make the maximum width size + let availableWidth: CGFloat = view.bounds.width + let maxWidth = min(Constants.maxSheetWidth, availableWidth) + let determinedWidth: CGFloat + if shouldAlwaysFillWidth { + determinedWidth = availableWidth + } else if Constants.minSheetWidth...maxWidth ~= preferredWidth { + determinedWidth = preferredWidth + } else { + determinedWidth = maxWidth + } + return determinedWidth } private func translationRubberBandFactor(for currentOffset: CGFloat) -> CGFloat { @@ -1064,6 +1135,9 @@ public class BottomSheetController: UIViewController, Shadowable, TokenizedContr // Minimum padding from top when the sheet is fully expanded static let minimumTopExpandedPadding: CGFloat = 25.0 + // The padding allocated to the space between the sheet and the edge when attached to the leading or trailing edge + static let horizontalSheetPadding: CGFloat = GlobalTokens.spacing(.size80) + static let expandedContentAlphaTransitionLength: CGFloat = 30 static let maxSheetWidth: CGFloat = 610