如何制作一个带有初始文本“选择一个”的Android下拉列表?

608
我想使用Spinner,它最初(当用户还没有进行选择时)显示文本“Select One”。当用户单击Spinner时,将显示项目列表,用户选择其中一个选项。用户进行选择后,所选项目将显示在Spinner中,而不是“Select One”。
我有以下代码来创建Spinner:
String[] items = new String[] {"One", "Two", "Three"};
Spinner spinner = (Spinner) findViewById(R.id.mySpinner);
ArrayAdapter<String> adapter = new ArrayAdapter<String>(this,
            android.R.layout.simple_spinner_item, items);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinner.setAdapter(adapter);

使用这段代码,最初显示的是“ One”项。我可以只添加一个新项目“选择一个”到项目中,但是“选择一个”也将作为第一项在下拉列表中显示,这不是我想要的。

如何解决这个问题?


7
完美的解决方案在这个问题中:https://dev59.com/Nmkw5IYBdhLWcg3wfKhA 只需覆盖getDropDownView()方法即可。 - Sourab Sharma
你尝试过将适配器的第一个元素设置为“选择一个”吗? - IgorGanapolsky
这里有另一个很棒的不错的解决方案! - AirtonCarneiro
可重复使用的Spinner:https://github.com/henrychuangtw/ReuseSpinner - HenryChuang
1
https://android--code.blogspot.in/2015/08/android-spinner-hint.html 另一个好的教程 - Prabs
有一个更好的解决方案 - 使用AutocompleteTextView,并将clickable和focusable设置为false。将项目添加为建议列表。将AutocompleteTextView包装在TextInputLayout中,您可以设置提示。因此,提示最初显示,当您单击它时,建议列表(您的项目)将显示出来。 clickable和focusable设置为false将防止键盘弹出和任何手动输入,基本上使其成为完美的下拉菜单。 - Chapz
37个回答

313
你可以使用一个装饰器来装饰你的SpinnerAdapter,最初为Spinner显示“选择选项...”视图,什么也没有被选中。
这是一个经过测试的工作示例,适用于Android 2.3和4.0(它不使用兼容库中的任何内容,所以应该可以使用一段时间)。由于它是一个装饰器,因此很容易将其应用于现有代码,并且它也可以与CursorLoader一起使用。(当然,交换包装的cursorAdapter上的游标...)
存在一个Android bug,使得重复使用视图变得更加困难。(因此,您必须使用setTag或其他方法来确保您的convertView正确)Spinner不支持多个视图类型 代码注释:2个构造函数
这允许您使用标准提示或定义自己的“未选择任何内容”作为第一行,或两者都不使用。(注意:某些主题显示下拉菜单作为Spinner而不是对话框。下拉菜单通常不显示提示)
你可以定义一个布局,使其“看起来”像一个提示,例如灰色...

Initial nothing selected

使用标准提示(请注意,没有选择任何内容):

With a standard prompt

或者带有提示和动态内容(也可以没有提示):

Prompt and nothing selected row

在上面的例子中的用法
Spinner spinner = (Spinner) findViewById(R.id.spinner);
ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(this, R.array.planets_array, android.R.layout.simple_spinner_item);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinner.setPrompt("Select your favorite Planet!");

spinner.setAdapter(
      new NothingSelectedSpinnerAdapter(
            adapter,
            R.layout.contact_spinner_row_nothing_selected,
            // R.layout.contact_spinner_nothing_selected_dropdown, // Optional
            this));

contact_spinner_row_nothing_selected.xml

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@android:id/text1"
    style="?android:attr/spinnerItemStyle"
    android:singleLine="true"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:ellipsize="marquee"
    android:textSize="18sp"
    android:textColor="#808080"
    android:text="[Select a Planet...]" />

NothingSelectedSpinnerAdapter.java

import android.content.Context;
import android.database.DataSetObserver;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ListAdapter;
import android.widget.SpinnerAdapter;

/**
 * Decorator Adapter to allow a Spinner to show a 'Nothing Selected...' initially
 * displayed instead of the first choice in the Adapter.
 */
public class NothingSelectedSpinnerAdapter implements SpinnerAdapter, ListAdapter {

    protected static final int EXTRA = 1;
    protected SpinnerAdapter adapter;
    protected Context context;
    protected int nothingSelectedLayout;
    protected int nothingSelectedDropdownLayout;
    protected LayoutInflater layoutInflater;

    /**
     * Use this constructor to have NO 'Select One...' item, instead use
     * the standard prompt or nothing at all.
     * @param spinnerAdapter wrapped Adapter.
     * @param nothingSelectedLayout layout for nothing selected, perhaps
     * you want text grayed out like a prompt...
     * @param context
     */
    public NothingSelectedSpinnerAdapter(
      SpinnerAdapter spinnerAdapter,
      int nothingSelectedLayout, Context context) {

        this(spinnerAdapter, nothingSelectedLayout, -1, context);
    }

    /**
     * Use this constructor to Define your 'Select One...' layout as the first
     * row in the returned choices.
     * If you do this, you probably don't want a prompt on your spinner or it'll
     * have two 'Select' rows.
     * @param spinnerAdapter wrapped Adapter. Should probably return false for isEnabled(0)
     * @param nothingSelectedLayout layout for nothing selected, perhaps you want
     * text grayed out like a prompt...
     * @param nothingSelectedDropdownLayout layout for your 'Select an Item...' in
     * the dropdown.
     * @param context
     */
    public NothingSelectedSpinnerAdapter(SpinnerAdapter spinnerAdapter,
            int nothingSelectedLayout, int nothingSelectedDropdownLayout, Context context) {
        this.adapter = spinnerAdapter;
        this.context = context;
        this.nothingSelectedLayout = nothingSelectedLayout;
        this.nothingSelectedDropdownLayout = nothingSelectedDropdownLayout;
        layoutInflater = LayoutInflater.from(context);
    }

    @Override
    public final View getView(int position, View convertView, ViewGroup parent) {
        // This provides the View for the Selected Item in the Spinner, not
        // the dropdown (unless dropdownView is not set).
        if (position == 0) {
            return getNothingSelectedView(parent);
        }
        return adapter.getView(position - EXTRA, null, parent); // Could re-use
                                                 // the convertView if possible.
    }

    /**
     * View to show in Spinner with Nothing Selected
     * Override this to do something dynamic... e.g. "37 Options Found"
     * @param parent
     * @return
     */
    protected View getNothingSelectedView(ViewGroup parent) {
        return layoutInflater.inflate(nothingSelectedLayout, parent, false);
    }

    @Override
    public View getDropDownView(int position, View convertView, ViewGroup parent) {
        // Android BUG! http://code.google.com/p/android/issues/detail?id=17128 -
        // Spinner does not support multiple view types
        if (position == 0) {
            return nothingSelectedDropdownLayout == -1 ?
              new View(context) :
              getNothingSelectedDropdownView(parent);
        }

        // Could re-use the convertView if possible, use setTag...
        return adapter.getDropDownView(position - EXTRA, null, parent);
    }

    /**
     * Override this to do something dynamic... For example, "Pick your favorite
     * of these 37".
     * @param parent
     * @return
     */
    protected View getNothingSelectedDropdownView(ViewGroup parent) {
        return layoutInflater.inflate(nothingSelectedDropdownLayout, parent, false);
    }

    @Override
    public int getCount() {
        int count = adapter.getCount();
        return count == 0 ? 0 : count + EXTRA;
    }

    @Override
    public Object getItem(int position) {
        return position == 0 ? null : adapter.getItem(position - EXTRA);
    }

    @Override
    public int getItemViewType(int position) {
        return 0;
    }

    @Override
    public int getViewTypeCount() {
        return 1;
    }

    @Override
    public long getItemId(int position) {
        return position >= EXTRA ? adapter.getItemId(position - EXTRA) : position - EXTRA;
    }

    @Override
    public boolean hasStableIds() {
        return adapter.hasStableIds();
    }

    @Override
    public boolean isEmpty() {
        return adapter.isEmpty();
    }

    @Override
    public void registerDataSetObserver(DataSetObserver observer) {
        adapter.registerDataSetObserver(observer);
    }

    @Override
    public void unregisterDataSetObserver(DataSetObserver observer) {
        adapter.unregisterDataSetObserver(observer);
    }

    @Override
    public boolean areAllItemsEnabled() {
        return false;
    }

    @Override
    public boolean isEnabled(int position) {
        return position != 0; // Don't allow the 'nothing selected'
                                             // item to be picked.
    }

}

57
这是一个优雅的解决方案。将代码复制粘贴到我的项目中后,代码完全有效。没有反射要求。+1。 - Richard Le Mesurier
2
这是一个很好的解决方案。如果有人想知道如何在所有时候而不仅仅是在选择项目之前覆盖标题,在getView()调用中,只需始终返回getNothingSelectedView(或任何其他自定义视图)。下拉列表仍将使用适配器中的项目进行填充,但您现在也可以在选择后控制标题。 - OldSchool4664
6
这真是一个非常优雅的解决方案,用于解决本不应存在的问题(尝试开发iPhone)。太好了,谢谢!很高兴有人记得模式等东西。 - Lars Christoffersen
3
@prashantwosti,代码已更新以适配Lollipop版本。具体而言是针对getItemViewType()和getViewTypeCount()进行了更新。 - aaronvargas
3
@aaronvargas 一旦从下拉列表中选择了一个项目,我能否撤销并选择“[选择一个星球]”? - modabeckham
显示剩余20条评论

265
这里有一个通用解决方案,它覆盖了 Spinner 视图。它重写了 setAdapter() 方法将初始位置设置为 -1,并代理提供的 SpinnerAdapter 来显示小于 0 的位置的提示字符串。
这已经在 Android 1.5 到 4.2 上进行了测试,但请注意!因为这个解决方案依赖反射调用私有的 AdapterView.setNextSelectedPositionInt()AdapterView.setSelectedPositionInt(),所以不能保证在未来的操作系统更新中能够正常工作。虽然似乎很可能会,但这并不是绝对的。
通常情况下我不会赞成这样做,但这个问题已经被问了很多次,而且看起来是一个合理的请求,所以我想发布我的解决方案。
/**
 * A modified Spinner that doesn't automatically select the first entry in the list.
 *
 * Shows the prompt if nothing is selected.
 *
 * Limitations: does not display prompt if the entry list is empty.
 */
public class NoDefaultSpinner extends Spinner {

    public NoDefaultSpinner(Context context) {
        super(context);
    }

    public NoDefaultSpinner(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public NoDefaultSpinner(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    @Override
    public void setAdapter(SpinnerAdapter orig ) {
        final SpinnerAdapter adapter = newProxy(orig);

        super.setAdapter(adapter);

        try {
            final Method m = AdapterView.class.getDeclaredMethod(
                               "setNextSelectedPositionInt",int.class);
            m.setAccessible(true);
            m.invoke(this,-1);

            final Method n = AdapterView.class.getDeclaredMethod(
                               "setSelectedPositionInt",int.class);
            n.setAccessible(true);
            n.invoke(this,-1);
        } 
        catch( Exception e ) {
            throw new RuntimeException(e);
        }
    }

    protected SpinnerAdapter newProxy(SpinnerAdapter obj) {
        return (SpinnerAdapter) java.lang.reflect.Proxy.newProxyInstance(
                obj.getClass().getClassLoader(),
                new Class[]{SpinnerAdapter.class},
                new SpinnerAdapterProxy(obj));
    }



    /**
     * Intercepts getView() to display the prompt if position < 0
     */
    protected class SpinnerAdapterProxy implements InvocationHandler {

        protected SpinnerAdapter obj;
        protected Method getView;


        protected SpinnerAdapterProxy(SpinnerAdapter obj) {
            this.obj = obj;
            try {
                this.getView = SpinnerAdapter.class.getMethod(
                                 "getView",int.class,View.class,ViewGroup.class);
            } 
            catch( Exception e ) {
                throw new RuntimeException(e);
            }
        }

        public Object invoke(Object proxy, Method m, Object[] args) throws Throwable {
            try {
                return m.equals(getView) && 
                       (Integer)(args[0])<0 ? 
                         getView((Integer)args[0],(View)args[1],(ViewGroup)args[2]) : 
                         m.invoke(obj, args);
            } 
            catch (InvocationTargetException e) {
                throw e.getTargetException();
            } 
            catch (Exception e) {
                throw new RuntimeException(e);
            }
        }

        protected View getView(int position, View convertView, ViewGroup parent) 
          throws IllegalAccessException {

            if( position<0 ) {
                final TextView v = 
                  (TextView) ((LayoutInflater)getContext().getSystemService(
                    Context.LAYOUT_INFLATER_SERVICE)).inflate(
                      android.R.layout.simple_spinner_item,parent,false);
                v.setText(getPrompt());
                return v;
            }
            return obj.getView(position,convertView,parent);
        }
    }
}

8
@emmby,你有没有想过如何在用户设置完选项后清除选择?我尝试将两个invoke()调用重构为clearSelection()方法,但它并没有真正起作用。虽然弹出列表显示先前选定的项目已取消选择,但微调控件仍然显示其为已选择状态,如果用户再次选择相同的项目,则不会调用onItemSelected()。 - Qwertie
5
请问如何使用上面的类? - Bishan
4
这个解决方案在Android 4.2(CyanogenMod 10.1)上不是100%完美,使用android:entries。膨胀的TextView的高度比默认适配器膨胀的任何资源的高度都要小。因此,当您实际选择一个选项时,高度会增加,在我的Galaxy S中约为10像素,这是不可接受的。我尝试了几种方法(重力,填充,边距等),但在设备之间没有一种可靠地工作,因此我将选择另一种解决方案。 - Maragues
3
你需要在布局文件中使用NoDefaultSpinner类。将上面的源代码复制到你的项目中,例如复制到包名为com.example.customviews下。现在,在你的布局xml中,用<com.example.customviews.NoDefaultSpinner ...> 替换 <Spinner ...>即可,其余代码保持不变。不要忘记在布局中的<com.example.customviews.NoDefaultSpinner>视图中添加android:prompt属性。 - Ridcully
2
如果您正在使用appcompat库,请让“NoDefaultSpinner”继承“AppCompatSpinner”。 - terencey
显示剩余15条评论

134

我最终使用了一个 Button。虽然 Button 不是 Spinner,但它的行为很容易自定义。

首先像通常一样创建适配器:

String[] items = new String[] {"One", "Two", "Three"};
ArrayAdapter<String> adapter = new ArrayAdapter<String>(this,
        android.R.layout.simple_spinner_dropdown_item, items);

请注意,我正在使用simple_spinner_dropdown_item作为布局ID。这将有助于在创建警报对话框时创建更好的外观。

在我的按钮的onClick处理程序中,我有:

public void onClick(View w) {
  new AlertDialog.Builder(this)
  .setTitle("the prompt")
  .setAdapter(adapter, new DialogInterface.OnClickListener() {

    @Override
    public void onClick(DialogInterface dialog, int which) {

      // TODO: user specific action

      dialog.dismiss();
    }
  }).create().show();
}

就是这样啦!


10
我同意那个答案。此外,相比Spinner,按钮更容易样式化。 - Romain Piel
2
这是一个布局完美的按钮 <Button android:id="@+id/city" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="5dp" android:gravity="left" android:background="@android:drawable/btn_dropdown" android:text="@string/city_prompt" /> - kml_ckr
那么,您将如何更新按钮的文本以反映所选内容,就像在微调器中发生的那样? - shim
@shim 你可以在对话框的onClick事件中使用myButton.setText("prompt")来设置按钮文本。 - shyamal
3
问题解决方案:只需使用SetSingleChoiceItems替换SetAdapter。 - Grzegorz Dev
显示剩余2条评论

68

首先,您可能对Spinner类的prompt属性感兴趣。如下图所示,“选择一个行星”是可以在XML中使用android:prompt=""设置的提示。

enter image description here

我本来想建议子类化Spinner,在内部维护两个适配器。一个带有“选择一个”的选项,另一个是真正的适配器(具有实际选项),然后在显示选择对话框之前使用OnClickListener切换适配器。然而,在尝试实现这个想法之后,我得出结论:您无法接收小部件本身的OnClick事件。

您可以将下拉列表包装在不同的视图中,拦截视图上的点击事件,然后告诉您的CustomSpinner切换适配器,但这似乎是一种可怕的hack方法。

您真的需要显示“选择一个”吗?


37
不仅需要显示“选择一个”的问题,它还解决了下拉菜单值可以选择留空的情况。 - greg7gkb
5
此选项还显示了“地球”作为Spinner上的选择,即使还没有选择任何内容。对于我的应用程序,我宁愿用户能够知道他们尚未选择任何内容。 - dylan murphy
3
这并没有真正回答问题。人们想要找到一种方法,使得旋转器默认显示“选择一个”而不是行星列表中的第一个项目,在这个例子中。 - JMRboosties

62

这段代码已经经过测试并在Android 4.4上运行正常

在此输入图片描述

Spinner spinner = (Spinner) activity.findViewById(R.id.spinner);
ArrayAdapter<String> adapter = new ArrayAdapter<String>(activity, android.R.layout.simple_spinner_dropdown_item) {

            @Override
            public View getView(int position, View convertView, ViewGroup parent) {

                View v = super.getView(position, convertView, parent);
                if (position == getCount()) {
                    ((TextView)v.findViewById(android.R.id.text1)).setText("");
                    ((TextView)v.findViewById(android.R.id.text1)).setHint(getItem(getCount())); //"Hint to be displayed"
                }

                return v;
            }       

            @Override
            public int getCount() {
                return super.getCount()-1; // you dont display last item. It is used as hint.
            }

        };

        adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
        adapter.add("Daily");
        adapter.add("Two Days");
        adapter.add("Weekly");
        adapter.add("Monthly");
        adapter.add("Three Months");
        adapter.add("HINT_TEXT_HERE"); //This is the text that will be displayed as hint.


        spinner.setAdapter(adapter);
        spinner.setSelection(adapter.getCount()); //set the hint the default selection so it appears on launch.
        spinner.setOnItemSelectedListener(this);

getItem(getCount()) 对我来说是红色下划线?无法解析方法 setHint。 - Zapnologica
我有一个疑问,在这个帖子中看到了很多解决方案,但为什么每个人都在最后一行添加提示。在第一行添加提示是不正确的吗? - Akash Patra
@akashpatra 他们在最后添加提示的原因是,Spinner的ArrayAdapter可能在运行时从不同的来源获取其值。 - VinKrish
这是最好的答案。 - Farshad Khodamoradi
如果没有选择任何内容并且包含片段视图被销毁,那么在片段恢复后,下拉列表中将显示上次选择的项目而不是提示。 - Sergey Stasishin
显示剩余3条评论

32

我找到了这个解决方案:

String[] items = new String[] {"Select One", "Two", "Three"};
Spinner spinner = (Spinner) findViewById(R.id.mySpinner);
ArrayAdapter<String> adapter = new ArrayAdapter<String>(this,
            android.R.layout.simple_spinner_item, items);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinner.setAdapter(adapter);

spinner.setOnItemSelectedListener(new OnItemSelectedListener() {
    @Override
    public void onItemSelected(AdapterView<?> arg0, View arg1, int position, long id) {
        items[0] = "One";
        selectedItem = items[position];
    }

    @Override
    public void onNothingSelected(AdapterView<?> arg0) {
    }
});

将数组的第一个元素更改为"Select One",然后在onItemSelected中将其重命名为"One"。

这不是一种优雅的解决方案,但它可以工作:D


6
对我来说这个方法不起作用。选择了“一”项目后,它仍然显示“选择一个”。 - Leo Landau
这不会起作用,因为onItemSelected接口总是会在第一次调用。 - Vaibhav Kadam

24

这里有很多答案,但我惊讶地发现没有人提出一个简单的解决方案:在Spinner上方放置一个TextView。在TextView上设置一个点击监听器,当点击时隐藏TextView并显示Spinner,并调用spinner.performClick()。


1
这是我最喜欢的答案。谢谢。 - iOS_Mouse
这种方法有一个小问题!为此,您需要将textview的宽度设置为spinner的宽度。这样,spinner的箭头图标就会消失!一种解决方法是增加层数,例如添加一个箭头层和一个线性布局,用于textview和箭头层。 - C.F.G

22

没有默认的API可以在Spinner上设置提示。要添加它,我们需要使用一种小技巧,否则就需要不安全的反射实现。

List<Object> objects = new ArrayList<Object>();
objects.add(firstItem);
objects.add(secondItem);
// add hint as last item
objects.add(hint);

HintAdapter adapter = new HintAdapter(context, objects, android.R.layout.simple_spinner_item);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);

Spinner spinnerFilmType = (Spinner) findViewById(R.id.spinner);
spinner.setAdapter(adapter);

// show hint
spinner.setSelection(adapter.getCount());

适配器来源:

public class HintAdapter
        extends ArrayAdapter<Objects> {

    public HintAdapter(Context theContext, List<Object> objects) {
        super(theContext, android.R.id.text1, android.R.id.text1, objects);
    }

    public HintAdapter(Context theContext, List<Object> objects, int theLayoutResId) {
        super(theContext, theLayoutResId, android.R.id.text1, objects);
    }

    @Override
    public int getCount() {
        // don't display last item. It is used as hint.
        int count = super.getCount();
        return count > 0 ? count - 1 : count;
    }
}

Original source


R.id.text1是什么?它是任何布局或视图吗?请详细说明您的答案。 - Anand Savjani
应该是 android.R.id.text1 - Yakiv Mospan
@akashpatra 我不记得具体情况,但好像当我试图将其作为列表的第一项时出现了一些问题。无论如何,您可以随时尝试并在此处发表评论,所有的魔法都围绕getCount方法。 - Yakiv Mospan
@YakivMospan,当我使用这个程序时,出现了“NPE”的错误提示,可能是因为使用了ProGuard反射。您知道如何解决这个问题吗? - Alan
@Alan,我的代码中没有反射。请分享您的崩溃日志和使用的代码,您可以为此创建另一个问题。 - Yakiv Mospan
显示剩余2条评论

9

我也遇到了spinner的同样问题,即空选择,但我找到了更好的解决方案。看一下这个简单的代码。

Spinner lCreditOrDebit = (Spinner)lCardPayView.findViewById(R.id.CARD_TYPE);
spinneradapter lAdapter = 
  new spinneradapter(
    BillPayScreen.this, 
    ndroid.R.layout.simple_spinner_item,getResources().getStringArray(R.array.creditordebit));
lAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
lCreditOrDebit.setAdapter(lAdapter);

这里的SpinnerAdapter是ArrayAdapter的一个小定制。它看起来像这样:

import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageView;

public class spinneradapter extends ArrayAdapter<String>{
    private Context m_cContext;
    public spinneradapter(Context context,int textViewResourceId, String[] objects) {
        super(context, textViewResourceId, objects);
        this.m_cContext = context;
    }

    boolean firsttime = true;
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        if(firsttime){
            firsttime = false;
            //Just return some empty view
            return new ImageView(m_cContext);
        }
        //Let the array adapter take care of it this time.
        return super.getView(position, convertView, parent);
    }
}

6
这种方法的问题在于,当列表弹出时仍会选择列表中的第一项。因为已经选择了它,所以不能触摸它进行选择 -- 它似乎没有发生过任何选择。 - jwadsack

7
你可以将其更改为文本视图并使用以下内容:
android:style="@android:style/Widget.DeviceDefault.Light.Spinner"

然后定义android:text属性。


仅适用于API 14及以上版本。 - Giulio Piancastelli
android:style="..." 还是 style="..."?我都试过了,但什么也没发生! - C.F.G

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接