개발환경/iOS

iOS 공유 확장 시트 높이 줄이기: detents가 안 먹힐 때 NSExtensionShareWantsFullScreenPresentation

공유 확장의 시트 높이는 확장 쪽에서 바꿀 수 없습니다. 네 번의 실패 끝에 공유 확장 전용 전체 화면 키로 하단 카드를 직접 그렸고, 흔히 보이는 Action 키와의 차이도 정리했습니다.

iOS 공유 확장 시트 높이 줄이기: detents가 안 먹힐 때 NSExtensionShareWantsFullScreenPresentation

다른 앱에서 링크를 공유하면 작은 카드가 아래에서 올라오고, 칩 하나를 누르면 저장되고 닫히는 화면을 만들고 싶었습니다. 내용은 제목 입력칸, 주소 한 줄, 저장 위치 칩 몇 개가 전부입니다. 그런데 iOS 공유 확장은 이 내용을 화면 거의 전체 높이의 흰 시트에 담아 보여줬습니다.

수정 전: 내용은 위쪽 몇 줄뿐인데 시트는 거의 전체 화면을 차지합니다

아래 빈 공간을 없애려고 시도한 것과 결과를 순서대로 적습니다. 모두 iOS 26.5 시뮬레이터, Release 빌드에서 확인했습니다.

효과가 없었던 네 가지

1. 카드를 아래에 붙이고 위쪽을 어둡게

SwiftUI 루트를 ZStack(alignment: .bottom)으로 바꾸고, 위쪽은 반투명 검정, 아래는 카드로 그렸습니다. 카드는 아래로 내려왔지만 위쪽에 회색 판이 그대로 남았습니다. 회색은 제가 칠한 반투명 검정이 시스템 시트의 흰 배경 위에 겹친 색이었습니다.

카드는 내려왔지만 위쪽에 시스템 시트가 회색 판으로 남은 모습

2. 배경을 투명하게

뷰 컨트롤러의 view.backgroundColor와 SwiftUI 배경을 모두 .clear로 바꿨습니다. 결과는 불투명한 흰 판이었습니다. 1번의 회색이 시스템 시트 자체의 배경이라는 것이 이걸로 확인됐습니다.

배경을 투명하게 해도 흰 시트가 그대로 보입니다

3. modalPresentationStyle = .overFullScreen

확장의 뷰 컨트롤러 init에서 표시 방식을 바꿨습니다. 화면은 달라지지 않았습니다.

4. 시트 높이(detents)를 직접 지정

확장 프로세스 안에서 표시 컨트롤러를 찍어 보니, 부모가 시트 컨트롤러로 떠 있었습니다.

PROBE sheet=nil parentSheet=Optional(<_UIFormSheetPresentationController: 0x106c48f00>) pc=Optional(<_UIOverFullscreenPresentationController: 0x105a08e00>)

그래서 화면이 뜬 뒤 그 시트의 높이를 300pt로 바꿨습니다.

override func viewDidAppear(_ animated: Bool) {
  super.viewDidAppear(animated)
  guard let sheet = parent?.sheetPresentationController else { return }
  sheet.animateChanges {
    sheet.detents = [.custom(identifier: .init("card")) { _ in 300 }]
    sheet.selectedDetentIdentifier = .init("card")
  }
}

로그에는 설정이 들어갔다고 찍혔습니다.

PROBE detents set [<UISheetPresentationControllerDetent: 0x109423ac0: _type=custom, _identifier=card>]

하지만 화면은 그대로 전체 높이였습니다.

원인: 보이는 시트는 호스트 앱이 그린다

Apple 개발자 포럼에 같은 질문이 있습니다. Apple 엔지니어의 답은 이렇습니다. 공유 확장은 large detent 하나만 가진 시트로 표시되고, 그 detents는 확장 쪽에서 바꿀 수 없습니다. 확장 프로세스 안의 시트 컨트롤러는 설정을 받아들이지만, 실제로 화면에 보이는 시트는 공유를 띄운 앱(호스트)이 그리기 때문에 반영되지 않습니다. 4번 시도가 바로 이 경우였습니다.

같은 답변에 대안도 있습니다. Info.plist 키로 전체 화면 표시를 요청하면 시트의 그림자, 어둡게 하기, 둥근 모서리 없이 전체 화면에 뜨고, 그 안에서 크기는 마음대로 정할 수 있습니다. 대신 시트의 기본 모습과 끌어내려 닫기는 사라집니다.

해결: NSExtensionShareWantsFullScreenPresentation

여기서 한 번 더 막혔습니다. 포럼 답변에 나온 키는 NSExtensionActionWantsFullScreenPresentation인데, 이 키를 넣고 빌드해 설치본 Info.plist에 들어간 것까지 확인했지만 화면은 그대로였습니다.

"NSExtensionActionWantsFullScreenPresentation" => true

이름 그대로 액션 확장용 키였습니다. 공유 확장은 Apple의 "What's New in Sharing" 세션에 나오는 **NSExtensionShareWantsFullScreenPresentation**을 써야 합니다. 키를 바꾸자 시스템 시트가 사라졌습니다.

<key>NSExtension</key>
<dict>
  <key>NSExtensionAttributes</key>
  <dict>
    <key>NSExtensionActivationRule</key>
    <dict>
      <key>NSExtensionActivationSupportsText</key>
      <true/>
      <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
      <integer>1</integer>
    </dict>
    <key>NSExtensionShareWantsFullScreenPresentation</key>  <!-- ← 핵심 -->
    <true/>
  </dict>
  <key>NSExtensionPointIdentifier</key>
  <string>com.apple.share-services</string>
  <key>NSExtensionPrincipalClass</key>
  <string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>

이제 확장의 뷰가 화면 전체를 받으므로, 어둡게 하기와 카드를 직접 그립니다. 뷰 컨트롤러와 호스팅 뷰의 배경은 투명하게 둡니다.

// ShareViewController.viewDidLoad
view.backgroundColor = .clear
let host = UIHostingController(rootView: ShareView(text: text, close: { [weak self] in
  self?.extensionContext?.completeRequest(returningItems: nil)
}))
host.view.backgroundColor = .clear  // ← 이것도 투명해야 아래 앱이 보입니다
struct ShareView: View {
  let text: String
  let close: () -> Void
  @State private var drag: CGFloat = 0  // 끌어내려 닫기는 직접 만듭니다

  var body: some View {
    ZStack(alignment: .bottom) {
      Color.black.opacity(0.4).ignoresSafeArea()
        .contentShape(Rectangle())
        .onTapGesture(perform: close)          // 바깥을 누르면 닫기
      card
    }
  }

  private var card: some View {
    VStack(alignment: .leading, spacing: 0) {
      Capsule().fill(.secondary).frame(width: 36, height: 4)
        .frame(maxWidth: .infinity).padding(.bottom, 12)
      // 제목, 주소, 저장 위치 칩 …
    }
    .padding(20)
    .frame(maxWidth: .infinity, alignment: .topLeading)
    .background(TopRounded(radius: 22).fill(Color(.systemBackground)).ignoresSafeArea(edges: .bottom))
    .offset(y: max(drag, 0))
    .gesture(DragGesture()
      .onChanged { drag = $0.translation.height }
      .onEnded { v in
        if v.translation.height > 100 { close() }
        else { withAnimation(.spring()) { drag = 0 } }
      })
  }
}

// 위쪽만 둥근 모양. UnevenRoundedRectangle은 iOS 17부터라, 16.x를 지원하면 직접 만듭니다.
struct TopRounded: Shape {
  let radius: CGFloat
  func path(in rect: CGRect) -> Path {
    Path(UIBezierPath(roundedRect: rect, byRoundingCorners: [.topLeft, .topRight],
                      cornerRadii: CGSize(width: radius, height: radius)).cgPath)
  }
}

결과입니다. 카드 뒤로 흐리게 보이는 것은 그 아래 깔린 iOS 공유 메뉴입니다.

전체 화면 표시로 바꾼 뒤: 내용 높이만 한 카드가 아래에 뜹니다

다크 모드는 색을 UIColor { trait in … } 동적 색으로 두면 그대로 따라옵니다.

같은 카드의 다크 모드

확인한 동작은 다섯 가지입니다. 칩을 누르면 저장 후 닫힘, 바깥을 누르면 닫힘, 100pt 이상 끌어내리면 닫힘, 라이트와 다크 모드 모두 정상입니다.

참고: 흰 배경만 걷어내는 비공식 방법

전체 화면 대신 시스템 시트를 유지한 채 흰 배경만 지우고 싶다면, iOS 26에서 같은 문제를 다룬 포럼 스레드에 뷰 계층을 거슬러 올라가며 배경을 모두 투명하게 만드는 우회법이 올라와 있습니다. 시스템이 소유한 뷰까지 건드리는 방법이라 다음 iOS에서 깨질 수 있습니다. 저는 Apple이 문서로 남긴 전체 화면 키 쪽을 택했습니다.

Android: 투명 액티비티는 배경을 직접 칠해야 합니다

Android 쪽은 SEND 인텐트를 받는 별도 액티비티를 투명 테마로 띄웠습니다. 처음에는 창 배경을 투명으로만 해 두어서, 칩과 글자가 공유한 앱 화면 위에 그대로 떠 보였습니다. 창이 투명하면 그 위의 레이아웃이 직접 배경을 칠해야 합니다.

<style name="ShareSheet" parent="@android:style/Theme.DeviceDefault.Light.NoActionBar">
  <item name="android:windowIsTranslucent">true</item>
  <item name="android:windowBackground">@android:color/transparent</item>
  <item name="android:backgroundDimEnabled">true</item>   <!-- ← 뒤 화면 어둡게 -->
  <item name="android:windowAnimationStyle">@android:style/Animation.InputMethod</item>
</style>

레이아웃은 전체 창 FrameLayout 안에 layout_gravity="bottom" 카드를 두고, 카드에 위쪽만 둥근 배경 drawable을 줍니다. 바깥 FrameLayout을 누르면 finish()로 닫습니다. 색은 values/와 values-night/에 같은 이름으로 두면 다크 모드를 따라갑니다.

Android 15부터는 edge-to-edge가 기본이라 카드가 내비게이션 바 밑으로 들어갑니다. 인셋만큼 아래 여백을 늘려야 합니다.

val base = card.paddingBottom
card.setOnApplyWindowInsetsListener { v, insets ->
  val bottom = insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime()).bottom
  v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, base + bottom)  // 키보드가 뜨면 그만큼 위로
  insets
}

Android 공유 액티비티의 하단 카드 (다크 모드)

다크 모드 색은 웹에서 CSS 변수로 테마를 나누는 것과 같은 방식으로, 색 이름은 하나로 두고 값만 모드별로 바꿨습니다(CSS 변수로 다크 모드 구현하기).

정리

  • 공유 확장 시트의 높이는 확장 쪽에서 바꿀 수 없습니다. detents를 설정해도 보이는 시트는 호스트가 그립니다.
  • 공유 확장의 전체 화면 키는 NSExtensionShareWantsFullScreenPresentation입니다. 흔히 보이는 NSExtensionActionWantsFullScreenPresentation은 액션 확장용이라 공유 확장에서는 효과가 없습니다.
  • 전체 화면으로 띄우면 어둡게 하기, 카드, 바깥 탭과 끌어내려 닫기를 직접 만들어야 합니다.

참고 자료