Show up in the "Open in..." menu on iOS

iOS中注册应用处理特定文档类型
本文介绍如何在iOS应用中注册特定文档类型,使应用能在‘打开方式’菜单中显示。通过在Info.plist中添加文档类型及其Uniform Type Identifier (UTI),并设置必要的属性如图标、角色和优先级等。

How do I get my application to show up in the "Open in..." menu on iOS for a specific document type?

Q:  How do I get my application to show up in the “Open in...” menu on iOS for a specific document type?

A: You need to register the document types that your application can open with iOS. To do this you need to add a document type to your app’s Info.plist for each document type that your app can open. Additionally if any of the document types are not known by iOS, you will need to provide an Uniform Type Identifier (UTI) for that document type.

Adding A Document Type

To add the document type do the following:

  1. In your Xcode project, select the target you want to add the document type to.

  2. Select the Info tab.

  3. Click on the disclosure button for Document Types to open the document types.

  4. Click the “+” button.

  5. In the newly created document type :

    • Type the name of the document type.

    • In the “Types” section fill in the UTI for the new type.

    • Provide an icon for the document.

  6. Click the disclosure triangle to open Additional document type properties.

  7. Click in the table to add a new key and value.

    • For the key value type: CFBundleTypeRole.

    • For the value type: Editor.

  8. Click the + button to add another key/value pair.

    • For the key value type: LSHandlerRank.

    • For the value type: Owner.

Adding A Custom UTI

If the document type you are adding is a custom document type, or a document type that iOS does not already know about, you will need to define the UTI for the document type. To add a new UTI do the following:

  1. In your Xcode project select the target you want to add the new UTI to.

  2. Select the Info tab.

  3. Click on the disclosure button for Exported UTIs.

  4. Click the “+” button.

  5. Select “Add Exported UTI”.

    • In the Description field, fill in a description of the UTI.

    • In the Identifier field, fill in the identifier for the UTI.

    • In the Conforms To field fill in the list of UTIs that this new UTI conforms to.

  6. Toggle the “Additional imported UTI properties” disclosure triangle to open up a table where you can add some additional information.

  7. Click in the empty table and a list of items that can be added to the table will be displayed.

  8. Type in “UTTypeTagSpecification”.

  9. Set the type to Dictionary.

  10. Click the disclosure triangle to open it, and click the + button in the table row to add an entry.

  11. For the “New item” change the name to “public.filename-extension”.

  12. For the type of the item change it to “Array”.

  13. Toggle open the item you just added and click the + button in the table row.

  14. For item 0 change the “value” to the file extension of your document. For example, txt, pdf, docx, etc.

Example

Here is a concrete example of a custom document type and exported UTI. Let’s say you were adding a new document type of cat information. If the document had the file extension 'catinfo' then when you were finished with the steps shown above, your information would look like this:

Figure 1  Example settings for a cat info document type.

How To Test Your Custom Document Type

The easiest way to test your custom document type is to email your custom file to your iOS device. Navigate to the email and ensure that the attachment is there. Tap and hold the document attachment icon. This should open a popover on the iPad, or an action sheet on the iPhone, that shows all of the apps that open your document type. Your app should show up in the list. Tap your app icon and your app should launch and receive the document from the email.

Troubleshooting Tips

If your property list is not set up correctly then attempting to open your custom document from mail may not work. Symptoms are that your custom app shows up in the list of apps for your custom document type but the attachment is never handed off to your app. Compare your custom document type to the one shown in Figure 1 of this Q&A. Carefully comparing your document type field by field to the example frequently reveals errors.

Here are a few things you should check when things are not working as expected:

  • The public.filename-extension key is spelled correctly.

  • The public.filename-extension key/value is defined as an array not a string.

  • Each item in the public.filename-extension array is a string that does not start with a dot. For example, txt not .txt.

  • The type in the document type and the identifier in the exported (or imported) UTI are exactly the same. Copying and pasting this value is the easiest way to ensure that the values are identical.


转自 https://developer.apple.com/library/ios/qa/qa1587/_index.html#//apple_ref/doc/uid/DTS40012659

<template> <view class="dropdown-wrapper"> <!-- 插槽:触发器 --> <slot name="reference" :open="open" :close="close" :toggle="toggle"> <!-- 默认触发器 --> <view class="dropdown-trigger-default" data-dropdown-trigger @tap="open"> 点击选择 ▼ </view> </slot> <!-- 浮动下拉菜单 --> <view v-if="visible" ref="dropdownRef" class="zy-popup-dropdown-menu" :style="{ top: `${top}px`, right: `${right}px`, transform: positionStyle?positionStyle:position, maxHeight: `${props.maxHeight}px` }" @touchmove.stop > <view class="dropdown-content"> <view v-for="item in props.data" :key="item[props.valueKey]" class="dropdown-item" :class="{ 'is-selected': props.modelValue === item[props.valueKey] }" @tap="handleSelect(item)" > <text class="item-label">{{ item[props.labelKey] }}</text> <uni-icons v-show="props.modelValue === item[props.valueKey]" class="icon-check" :type="checkIcon" color="#0f56d5" /> </view> </view> </view> </view> </template> <script setup> import { ref, nextTick, onUnmounted } from 'vue' // ---------------------- // Props 定义 // ---------------------- const props = defineProps({ // 数据源 [{ label: 'xxx', value: '1' }] data: { type: Array, required: true }, // 当前选中值(v-model) modelValue: { type: [String, Number, Boolean, null], default: null }, // 显示字段名 labelKey: { type: String, default: 'name' }, // 值字段名 valueKey: { type: String, default: 'value' }, // id名 trigger: { type: String, default: '', required: true }, checkIcon: { // 选中时候的icon type: String, default: 'checkmarkempty' }, scrollClose: { // 页面滚动是否关闭已打开组件 type: Boolean, default: true }, maxHeight: { // 展开组件的最大高度(仅传入数字即可,需要进行计算,单位是px) type: Number, default: 486 }, positionStyle: { // 展开组件的position样式 type: String, default: '' } }) // ---------------------- // Emits // ---------------------- const emit = defineEmits(['update:modelValue', 'change', 'open', 'close']) // ---------------------- // 内部状态 // ---------------------- const visible = ref(false) const top = ref(0) const left = ref(0) const right = ref(12) const position = ref('translateY(8px)') const dropdownRef = ref(null) let observer = null // ---------------------- // 获取触发器位置(核心方法) function getTriggerRect() { return new Promise(resolve => { // console.log('🚀 ~ getTriggerRect ~ props.trigger:', props.trigger) uni .createSelectorQuery() .select('#' + props.trigger) .boundingClientRect(rect => { if (rect) { // console.log('✅ 定位成功:', rect) resolve(rect) } else { // console.error('❌ 查询失败,请确认 ID 是否正确或已渲染') resolve(null) } }) .exec() }) } // ---------------------- // 打开下拉框 // ---------------------- const open = async () => { if (visible.value) { close() return } const rect = await getTriggerRect() if (!rect) return doOpen(rect) // 抽离打开逻辑 } const doOpen = rect => { // 获取设备系统信息(同步) const res = uni.getSystemInfoSync() const windowHeight = Number(res.windowHeight) // 可视窗口高度(单位 px) const windowWidth = Number(res.windowWidth) // 可视窗口宽度(单位 px) const maxHeight = props.maxHeight // 弹窗最大高度(CSS 中设定的最大高度) const menuWidth = 215 // 弹窗固定宽度,必须与 CSS 一致(避免计算偏差) const rightGap = 12 // 弹窗右侧留白距离(安全边距) console.log('【doOpen】触发打开菜单', { res, rect, windowHeight, windowWidth }) // 计算触发元素上下方可用空间 const spaceBelow = windowHeight - rect.bottom // 下方剩余空间 const spaceAbove = rect.top // 上方剩余空间 // 判断上下空间是否都不足以放下弹窗(maxHeight) const isBothDirectionsInsufficient = spaceAbove < maxHeight && spaceBelow < maxHeight console.log('【空间判断】', { spaceAbove, // 上方还有多少空间 spaceBelow, // 下方还有多少空间 maxHeight, // 需要的高度 isBothDirectionsInsufficient: isBothDirectionsInsufficient // 是否上下都不够 }) let finalTop, finalLeft, finalTransform if (isBothDirectionsInsufficient) { // ======================== // 🎯 情况1:上下空间都不够 → 居中显示 // ======================== // 🔹 Y 轴定位:将弹窗垂直居中于屏幕中央 finalTop = windowHeight / 2 // 🔹 使用 transform 实现真正的居中(X 和 Y 方向都偏移自身尺寸的 50%) finalTransform = 'translate(0, -50%)' // 🔹 X 轴定位:右对齐,距离右侧边界 12px finalLeft = windowWidth - menuWidth - rightGap // ⚠️ 安全检查:防止弹窗左侧溢出屏幕(比如小屏设备) if (finalLeft < 0) { console.warn('【警告】弹窗左溢,修正为 0') finalLeft = 0 } console.log('【居中模式】启用屏幕中心定位', { finalTop, finalLeft, finalTransform }) } else { // ======================== // 📐 情况2:至少有一侧空间足够 → 正常弹出(向上或向下) // ======================== // 根据下方空间是否足够决定方向 const needUpward = spaceBelow < maxHeight // 下方不够就往上弹 if (needUpward) { // 向上展开:弹窗底部对齐触发元素顶部,再留 8px 间隙 finalTop = rect.top - maxHeight - 8 finalTransform = 'translateY(8px)' // 微调:让动画有“出现”感(可选) } else { // 向下展开:弹窗顶部对齐触发元素底部,再留 8px 间隙 finalTop = rect.bottom + 8 finalTransform = 'translateY(-8px)' // 微调偏移 } // 🔹 水平方向统一右对齐(距离右边 12px) finalLeft = '12px' console.log('【常规模式】根据方向定位', { direction: needUpward ? '向上弹出' : '向下弹出', finalTop, finalLeft, finalTransform }) } // ✅ 更新响应式数据,驱动 UI 更新 top.value = finalTop left.value = finalLeft // right.value = finalLeft position.value = finalTransform console.log('✅ 【最终定位结果】已设置:', { top: top.value, left: left.value, // right: right.value, transform: position.value }) // 显示弹窗 visible.value = true // 触发 open 事件,携带当前 modelValue emit('open', props.modelValue, props.trigger) // 在下一次 DOM 更新后绑定外部点击和滚动监听 nextTick(() => { console.log('🔧 执行 nextTick,准备绑定事件监听...') bindOutsideClickListener() bindScrollListener() }) } // ---------------------- // 关闭 & 切换 & 选择 // ---------------------- const close = () => { if (!visible.value) return visible.value = false emit('close', props.trigger) removeListeners() } const toggle = () => { visible.value ? close() : open() } const handleSelect = item => { const value = item[props.valueKey] const label = item[props.labelKey] // 动态构造返回对象,key 来自 props const selected = { [props.valueKey]: value, [props.labelKey]: label } // 检查 modelValue 是否变化 if (props.modelValue !== value) { emit('update:modelValue', value, props.trigger) emit('change', selected, props.trigger) } close() } // ---------------------- // 外部点击关闭 // ---------------------- const bindOutsideClickListener = () => { const handler = e => { // 阻止事件冒泡时也能监听到页面点击 const path = e.path || [] const isInside = path.some(node => node?.dataset?.dropdownTrigger) if (!isInside) close() } uni.$on('onPageTap', handler) observer = { ...observer, cleanupTap: () => uni.$off('onPageTap', handler) } } // ---------------------- // 页面滚动关闭 // ---------------------- const bindScrollListener = () => { const scrollHandler = res => { if (!visible.value) return // 滚动时重新定位 // repositionDebounced() console.log('🚀 ~ bindScrollListener ~ props.scrollClose:', props.scrollClose) if (props.scrollClose) return close() reposition() } uni.$on('onPageScroll', scrollHandler) observer = { ...observer, cleanupScroll: () => uni.$off('onPageScroll', scrollHandler) } } // 新增:重新计算并设置位置 // const reposition = async () => { // const rect = await getTriggerRect() // if (!rect) return // const res = uni.getSystemInfoSync() // const windowHeight = res.windowHeight // const maxHeight = props.maxHeight // const needUpward = windowHeight - rect.bottom < maxHeight // top.value = needUpward ? rect.top - maxHeight - 8 : rect.bottom + 8 // position.value = needUpward ? 'translateY(-8px)' : 'translateY(8px)' // } const reposition = async () => { // 获取触发元素的边界信息 const rect = await getTriggerRect() if (!rect) { console.warn('【reposition】无法获取触发元素位置,跳过重定位') return } console.log('【reposition】开始重新定位', { rect }) const res = uni.getSystemInfoSync() const windowHeight = Number(res.windowHeight) const windowWidth = Number(res.windowWidth) const maxHeight = props.maxHeight // 弹窗最大高度 const menuWidth = 215 // 必须与 CSS 一致 const rightGap = 12 // 右侧边距 // 计算上下可用空间 const spaceBelow = windowHeight - rect.bottom const spaceAbove = rect.top const isBothDirectionsInsufficient = spaceAbove < maxHeight && spaceBelow < maxHeight console.log('【reposition - 空间判断】', { spaceAbove, spaceBelow, maxHeight, isBothDirectionsInsufficient }) let finalTop, finalLeft, finalTransform if (isBothDirectionsInsufficient) { // ======================== // 🎯 情况1:上下都不够 → 屏幕居中 + 右对齐 // ======================== finalTop = windowHeight / 2 finalTransform = 'translate(0, -50%)' // Y轴居中,X不偏移(left 已经精确设置) finalLeft = windowWidth - menuWidth - rightGap if (finalLeft < 0) { console.warn('【reposition】左溢修正', finalLeft) finalLeft = 0 } console.log('【reposition - 居中模式】', { finalTop, finalLeft, finalTransform }) } else { // ======================== // 📐 情况2:正常方向展开 // ======================== const needUpward = spaceBelow < maxHeight finalTop = needUpward ? rect.top - maxHeight - 8 : rect.bottom + 8 finalTransform = needUpward ? 'translateY(-8px)' : 'translateY(8px)' finalLeft = windowWidth - menuWidth - rightGap if (finalLeft < 0) { console.warn('【reposition】右对齐导致左溢,修正为 0') finalLeft = 0 } console.log('【reposition - 常规模式】', { direction: needUpward ? '向上' : '向下', finalTop, finalLeft, finalTransform }) } // ✅ 更新响应式变量 top.value = finalTop left.value = finalLeft position.value = finalTransform console.log('✅ 【reposition 成功】更新定位:', { top: top.value, left: left.value, transform: position.value }) } const debounce = (fn, time = 10) => { let timer = null return function (...args) { if (timer) clearTimeout(timer) timer = setTimeout(() => { fn.apply(this, args) }, time) } } const repositionDebounced = debounce(reposition, 50) // ---------------------- // 移除监听 // ---------------------- const removeListeners = () => { observer?.cleanupTap?.() observer?.cleanupScroll?.() observer = null } // ---------------------- // 暴露方法给父组件调用 // ---------------------- defineExpose({ open, close, toggle }) // ---------------------- // 卸载清理 // ---------------------- onUnmounted(() => { removeListeners() }) </script> <style scoped> /* 整体容器 */ .dropdown-wrapper { display: inline-block; } /* 默认触发器样式 */ .dropdown-trigger-default { display: inline-flex; align-items: center; justify-content: center; padding: 8px 16px; background-color: #fff; border: 1px solid #ddd; border-radius: 6px; font-size: 15px; color: #333; } /* 下拉菜单主体 */ .zy-popup-dropdown-menu { position: fixed; width: 215px; background-color: #ffffff; border-radius: 8px; z-index: 9999; display: flex; flex-direction: column; box-shadow: 0 4px 5px -3px rgba(0, 0, 0, 0.08), 0 8px 12px 1px rgba(0, 0, 0, 0.04), 0 3px 15px 3px rgba(0, 0, 0, 0.05); } /* 内容区可滚动 */ .dropdown-content { flex: 1; height: 0; /* 防止撑开 */ max-height: 486px; overflow-y: auto; -webkit-overflow-scrolling: touch; /* iOS 平滑滚动 */ } /* 每一项 */ .dropdown-item { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; font-size: 15px; color: #333333; border-bottom: 1px solid #f5f5f5; } .dropdown-item:last-child { border-bottom: none; } /* 选中项样式 */ .dropdown-item.is-selected { color: #0f56d5; font-weight: 500; } /* 文本自动换行 */ .item-label { flex: 1; word-break: break-word; line-height: 1.4; text-align: left; } /* 对号图标 */ .icon-check { font-family: 'erda' !important; /* 可替换为 iconfont 字体 */ font-size: 16px; margin-left: 8px; color: #0f56d5; } </style> 修改当前组件,让滚动区域使用,确保复用当前组件样式,返回我完整的代码
11-01
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值